@fresh-editor/fresh-editor 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/plugins/README.md +46 -96
- package/plugins/examples/README.md +51 -211
- package/plugins/lib/fresh.d.ts +42 -1
- package/plugins/markdown_compose.ts +475 -235
- package/plugins/manual_help.ts +0 -243
|
@@ -78,6 +78,271 @@ interface LayoutHints {
|
|
|
78
78
|
column_guides?: number[] | null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// Block-based parser for hanging indent support
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
interface ParsedBlock {
|
|
86
|
+
type: 'paragraph' | 'list-item' | 'ordered-list' | 'checkbox' | 'blockquote' |
|
|
87
|
+
'heading' | 'code-fence' | 'code-content' | 'hr' | 'empty' | 'image';
|
|
88
|
+
startByte: number; // First byte of the line
|
|
89
|
+
endByte: number; // Byte after last char (before newline)
|
|
90
|
+
leadingIndent: number; // Spaces before marker/content
|
|
91
|
+
marker: string; // "- ", "1. ", "> ", "## ", etc.
|
|
92
|
+
markerStartByte: number; // Where marker begins
|
|
93
|
+
contentStartByte: number; // Where content begins (after marker)
|
|
94
|
+
content: string; // The actual text content (after marker)
|
|
95
|
+
hangingIndent: number; // Continuation indent for wrapped lines
|
|
96
|
+
forceHardBreak: boolean; // Should this block end with hard newline?
|
|
97
|
+
headingLevel?: number; // For headings (1-6)
|
|
98
|
+
checked?: boolean; // For checkboxes
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a markdown document into blocks with structure info for wrapping
|
|
103
|
+
*/
|
|
104
|
+
function parseMarkdownBlocks(text: string): ParsedBlock[] {
|
|
105
|
+
const blocks: ParsedBlock[] = [];
|
|
106
|
+
const lines = text.split('\n');
|
|
107
|
+
let byteOffset = 0;
|
|
108
|
+
let inCodeBlock = false;
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < lines.length; i++) {
|
|
111
|
+
const line = lines[i];
|
|
112
|
+
const lineStart = byteOffset;
|
|
113
|
+
const lineEnd = byteOffset + line.length;
|
|
114
|
+
|
|
115
|
+
// Code block detection
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (trimmed.startsWith('```')) {
|
|
118
|
+
inCodeBlock = !inCodeBlock;
|
|
119
|
+
blocks.push({
|
|
120
|
+
type: 'code-fence',
|
|
121
|
+
startByte: lineStart,
|
|
122
|
+
endByte: lineEnd,
|
|
123
|
+
leadingIndent: line.length - line.trimStart().length,
|
|
124
|
+
marker: '',
|
|
125
|
+
markerStartByte: lineStart,
|
|
126
|
+
contentStartByte: lineStart,
|
|
127
|
+
content: line,
|
|
128
|
+
hangingIndent: 0,
|
|
129
|
+
forceHardBreak: true,
|
|
130
|
+
});
|
|
131
|
+
byteOffset = lineEnd + 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (inCodeBlock) {
|
|
136
|
+
blocks.push({
|
|
137
|
+
type: 'code-content',
|
|
138
|
+
startByte: lineStart,
|
|
139
|
+
endByte: lineEnd,
|
|
140
|
+
leadingIndent: 0,
|
|
141
|
+
marker: '',
|
|
142
|
+
markerStartByte: lineStart,
|
|
143
|
+
contentStartByte: lineStart,
|
|
144
|
+
content: line,
|
|
145
|
+
hangingIndent: 0,
|
|
146
|
+
forceHardBreak: true,
|
|
147
|
+
});
|
|
148
|
+
byteOffset = lineEnd + 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Empty line
|
|
153
|
+
if (trimmed.length === 0) {
|
|
154
|
+
blocks.push({
|
|
155
|
+
type: 'empty',
|
|
156
|
+
startByte: lineStart,
|
|
157
|
+
endByte: lineEnd,
|
|
158
|
+
leadingIndent: 0,
|
|
159
|
+
marker: '',
|
|
160
|
+
markerStartByte: lineStart,
|
|
161
|
+
contentStartByte: lineStart,
|
|
162
|
+
content: '',
|
|
163
|
+
hangingIndent: 0,
|
|
164
|
+
forceHardBreak: true,
|
|
165
|
+
});
|
|
166
|
+
byteOffset = lineEnd + 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Headers: # Heading
|
|
171
|
+
const headerMatch = line.match(/^(\s*)(#{1,6})\s+(.*)$/);
|
|
172
|
+
if (headerMatch) {
|
|
173
|
+
const leadingIndent = headerMatch[1].length;
|
|
174
|
+
const marker = headerMatch[2] + ' ';
|
|
175
|
+
const content = headerMatch[3];
|
|
176
|
+
blocks.push({
|
|
177
|
+
type: 'heading',
|
|
178
|
+
startByte: lineStart,
|
|
179
|
+
endByte: lineEnd,
|
|
180
|
+
leadingIndent,
|
|
181
|
+
marker,
|
|
182
|
+
markerStartByte: lineStart + leadingIndent,
|
|
183
|
+
contentStartByte: lineStart + leadingIndent + marker.length,
|
|
184
|
+
content,
|
|
185
|
+
hangingIndent: 0,
|
|
186
|
+
forceHardBreak: true,
|
|
187
|
+
headingLevel: headerMatch[2].length,
|
|
188
|
+
});
|
|
189
|
+
byteOffset = lineEnd + 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Horizontal rule
|
|
194
|
+
if (trimmed.match(/^(-{3,}|\*{3,}|_{3,})$/)) {
|
|
195
|
+
blocks.push({
|
|
196
|
+
type: 'hr',
|
|
197
|
+
startByte: lineStart,
|
|
198
|
+
endByte: lineEnd,
|
|
199
|
+
leadingIndent: line.length - line.trimStart().length,
|
|
200
|
+
marker: '',
|
|
201
|
+
markerStartByte: lineStart,
|
|
202
|
+
contentStartByte: lineStart,
|
|
203
|
+
content: line,
|
|
204
|
+
hangingIndent: 0,
|
|
205
|
+
forceHardBreak: true,
|
|
206
|
+
});
|
|
207
|
+
byteOffset = lineEnd + 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Checkbox: - [ ] or - [x]
|
|
212
|
+
const checkboxMatch = line.match(/^(\s*)([-*+])\s+(\[[ x]\])\s+(.*)$/);
|
|
213
|
+
if (checkboxMatch) {
|
|
214
|
+
const leadingIndent = checkboxMatch[1].length;
|
|
215
|
+
const bullet = checkboxMatch[2];
|
|
216
|
+
const checkbox = checkboxMatch[3];
|
|
217
|
+
const marker = bullet + ' ' + checkbox + ' ';
|
|
218
|
+
const content = checkboxMatch[4];
|
|
219
|
+
const checked = checkbox === '[x]';
|
|
220
|
+
blocks.push({
|
|
221
|
+
type: 'checkbox',
|
|
222
|
+
startByte: lineStart,
|
|
223
|
+
endByte: lineEnd,
|
|
224
|
+
leadingIndent,
|
|
225
|
+
marker,
|
|
226
|
+
markerStartByte: lineStart + leadingIndent,
|
|
227
|
+
contentStartByte: lineStart + leadingIndent + marker.length,
|
|
228
|
+
content,
|
|
229
|
+
hangingIndent: leadingIndent + marker.length,
|
|
230
|
+
forceHardBreak: true,
|
|
231
|
+
checked,
|
|
232
|
+
});
|
|
233
|
+
byteOffset = lineEnd + 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Unordered list: - item or * item or + item
|
|
238
|
+
const bulletMatch = line.match(/^(\s*)([-*+])\s+(.*)$/);
|
|
239
|
+
if (bulletMatch) {
|
|
240
|
+
const leadingIndent = bulletMatch[1].length;
|
|
241
|
+
const bullet = bulletMatch[2];
|
|
242
|
+
const marker = bullet + ' ';
|
|
243
|
+
const content = bulletMatch[3];
|
|
244
|
+
blocks.push({
|
|
245
|
+
type: 'list-item',
|
|
246
|
+
startByte: lineStart,
|
|
247
|
+
endByte: lineEnd,
|
|
248
|
+
leadingIndent,
|
|
249
|
+
marker,
|
|
250
|
+
markerStartByte: lineStart + leadingIndent,
|
|
251
|
+
contentStartByte: lineStart + leadingIndent + marker.length,
|
|
252
|
+
content,
|
|
253
|
+
hangingIndent: leadingIndent + marker.length,
|
|
254
|
+
forceHardBreak: true,
|
|
255
|
+
});
|
|
256
|
+
byteOffset = lineEnd + 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Ordered list: 1. item
|
|
261
|
+
const orderedMatch = line.match(/^(\s*)(\d+\.)\s+(.*)$/);
|
|
262
|
+
if (orderedMatch) {
|
|
263
|
+
const leadingIndent = orderedMatch[1].length;
|
|
264
|
+
const number = orderedMatch[2];
|
|
265
|
+
const marker = number + ' ';
|
|
266
|
+
const content = orderedMatch[3];
|
|
267
|
+
blocks.push({
|
|
268
|
+
type: 'ordered-list',
|
|
269
|
+
startByte: lineStart,
|
|
270
|
+
endByte: lineEnd,
|
|
271
|
+
leadingIndent,
|
|
272
|
+
marker,
|
|
273
|
+
markerStartByte: lineStart + leadingIndent,
|
|
274
|
+
contentStartByte: lineStart + leadingIndent + marker.length,
|
|
275
|
+
content,
|
|
276
|
+
hangingIndent: leadingIndent + marker.length,
|
|
277
|
+
forceHardBreak: true,
|
|
278
|
+
});
|
|
279
|
+
byteOffset = lineEnd + 1;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Block quote: > text
|
|
284
|
+
const quoteMatch = line.match(/^(\s*)(>)\s*(.*)$/);
|
|
285
|
+
if (quoteMatch) {
|
|
286
|
+
const leadingIndent = quoteMatch[1].length;
|
|
287
|
+
const marker = '> ';
|
|
288
|
+
const content = quoteMatch[3];
|
|
289
|
+
blocks.push({
|
|
290
|
+
type: 'blockquote',
|
|
291
|
+
startByte: lineStart,
|
|
292
|
+
endByte: lineEnd,
|
|
293
|
+
leadingIndent,
|
|
294
|
+
marker,
|
|
295
|
+
markerStartByte: lineStart + leadingIndent,
|
|
296
|
+
contentStartByte: lineStart + leadingIndent + 2, // "> " is 2 chars
|
|
297
|
+
content,
|
|
298
|
+
hangingIndent: leadingIndent + 2,
|
|
299
|
+
forceHardBreak: true,
|
|
300
|
+
});
|
|
301
|
+
byteOffset = lineEnd + 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Image: 
|
|
306
|
+
if (trimmed.match(/^!\[.*\]\(.*\)$/)) {
|
|
307
|
+
blocks.push({
|
|
308
|
+
type: 'image',
|
|
309
|
+
startByte: lineStart,
|
|
310
|
+
endByte: lineEnd,
|
|
311
|
+
leadingIndent: line.length - line.trimStart().length,
|
|
312
|
+
marker: '',
|
|
313
|
+
markerStartByte: lineStart,
|
|
314
|
+
contentStartByte: lineStart,
|
|
315
|
+
content: line,
|
|
316
|
+
hangingIndent: 0,
|
|
317
|
+
forceHardBreak: true,
|
|
318
|
+
});
|
|
319
|
+
byteOffset = lineEnd + 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Hard break (trailing spaces or backslash)
|
|
324
|
+
const hasHardBreak = line.endsWith(' ') || line.endsWith('\\');
|
|
325
|
+
|
|
326
|
+
// Default: paragraph
|
|
327
|
+
const leadingIndent = line.length - line.trimStart().length;
|
|
328
|
+
blocks.push({
|
|
329
|
+
type: 'paragraph',
|
|
330
|
+
startByte: lineStart,
|
|
331
|
+
endByte: lineEnd,
|
|
332
|
+
leadingIndent,
|
|
333
|
+
marker: '',
|
|
334
|
+
markerStartByte: lineStart + leadingIndent,
|
|
335
|
+
contentStartByte: lineStart + leadingIndent,
|
|
336
|
+
content: trimmed,
|
|
337
|
+
hangingIndent: leadingIndent, // Paragraph continuation aligns with first line
|
|
338
|
+
forceHardBreak: hasHardBreak,
|
|
339
|
+
});
|
|
340
|
+
byteOffset = lineEnd + 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return blocks;
|
|
344
|
+
}
|
|
345
|
+
|
|
81
346
|
// Colors for styling (RGB tuples)
|
|
82
347
|
const COLORS = {
|
|
83
348
|
header: [100, 149, 237] as [number, number, number], // Cornflower blue
|
|
@@ -741,157 +1006,23 @@ function highlightLine(
|
|
|
741
1006
|
}
|
|
742
1007
|
}
|
|
743
1008
|
|
|
744
|
-
// Clear highlights for a buffer
|
|
745
|
-
function clearHighlights(bufferId: number): void {
|
|
746
|
-
editor.clearNamespace(bufferId, "md");
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// Build view transform with soft breaks
|
|
750
|
-
function buildViewTransform(
|
|
751
|
-
bufferId: number,
|
|
752
|
-
splitId: number | null,
|
|
753
|
-
text: string,
|
|
754
|
-
viewportStart: number,
|
|
755
|
-
viewportEnd: number,
|
|
756
|
-
tokens: Token[]
|
|
757
|
-
): void {
|
|
758
|
-
const viewTokens: ViewTokenWire[] = [];
|
|
759
|
-
|
|
760
|
-
// Get the relevant portion of text
|
|
761
|
-
const viewportText = text.substring(viewportStart, viewportEnd);
|
|
762
|
-
|
|
763
|
-
// Track which lines should have hard breaks
|
|
764
|
-
let lineStart = viewportStart;
|
|
765
|
-
let i = 0;
|
|
766
|
-
|
|
767
|
-
while (i < viewportText.length) {
|
|
768
|
-
const absOffset = viewportStart + i;
|
|
769
|
-
const ch = viewportText[i];
|
|
770
|
-
|
|
771
|
-
if (ch === '\n') {
|
|
772
|
-
// Check if this line should have a hard break
|
|
773
|
-
const hasHardBreak = tokens.some(t =>
|
|
774
|
-
(t.type === TokenType.HardBreak ||
|
|
775
|
-
t.type === TokenType.Header1 ||
|
|
776
|
-
t.type === TokenType.Header2 ||
|
|
777
|
-
t.type === TokenType.Header3 ||
|
|
778
|
-
t.type === TokenType.Header4 ||
|
|
779
|
-
t.type === TokenType.Header5 ||
|
|
780
|
-
t.type === TokenType.Header6 ||
|
|
781
|
-
t.type === TokenType.ListItem ||
|
|
782
|
-
t.type === TokenType.OrderedListItem ||
|
|
783
|
-
t.type === TokenType.Checkbox ||
|
|
784
|
-
t.type === TokenType.BlockQuote ||
|
|
785
|
-
t.type === TokenType.CodeBlockFence ||
|
|
786
|
-
t.type === TokenType.CodeBlockContent ||
|
|
787
|
-
t.type === TokenType.HorizontalRule ||
|
|
788
|
-
t.type === TokenType.Image) &&
|
|
789
|
-
t.start <= lineStart && t.end >= lineStart
|
|
790
|
-
);
|
|
791
|
-
|
|
792
|
-
// Empty lines are also hard breaks
|
|
793
|
-
const lineContent = viewportText.substring(lineStart - viewportStart, i).trim();
|
|
794
|
-
const isEmptyLine = lineContent.length === 0;
|
|
795
|
-
|
|
796
|
-
if (hasHardBreak || isEmptyLine) {
|
|
797
|
-
// Hard break - keep newline
|
|
798
|
-
viewTokens.push({
|
|
799
|
-
source_offset: absOffset,
|
|
800
|
-
kind: "Newline",
|
|
801
|
-
});
|
|
802
|
-
} else {
|
|
803
|
-
// Soft break - replace with space
|
|
804
|
-
viewTokens.push({
|
|
805
|
-
source_offset: absOffset,
|
|
806
|
-
kind: "Space",
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
lineStart = absOffset + 1;
|
|
811
|
-
i++;
|
|
812
|
-
} else if (ch === ' ') {
|
|
813
|
-
viewTokens.push({
|
|
814
|
-
source_offset: absOffset,
|
|
815
|
-
kind: "Space",
|
|
816
|
-
});
|
|
817
|
-
i++;
|
|
818
|
-
} else {
|
|
819
|
-
// Accumulate consecutive text characters
|
|
820
|
-
let textStart = i;
|
|
821
|
-
let textContent = '';
|
|
822
|
-
while (i < viewportText.length) {
|
|
823
|
-
const c = viewportText[i];
|
|
824
|
-
if (c === '\n' || c === ' ') {
|
|
825
|
-
break;
|
|
826
|
-
}
|
|
827
|
-
textContent += c;
|
|
828
|
-
i++;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
viewTokens.push({
|
|
832
|
-
source_offset: viewportStart + textStart,
|
|
833
|
-
kind: { Text: textContent },
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// Submit the view transform with layout hints
|
|
839
|
-
const layoutHints: LayoutHints = {
|
|
840
|
-
compose_width: config.composeWidth,
|
|
841
|
-
column_guides: null,
|
|
842
|
-
};
|
|
843
|
-
|
|
844
|
-
editor.debug(`buildViewTransform: submitting ${viewTokens.length} tokens, compose_width=${config.composeWidth}`);
|
|
845
|
-
if (viewTokens.length > 0 && viewTokens.length < 10) {
|
|
846
|
-
editor.debug(`buildViewTransform: first tokens: ${JSON.stringify(viewTokens.slice(0, 5))}`);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const success = editor.submitViewTransform(
|
|
850
|
-
bufferId,
|
|
851
|
-
splitId,
|
|
852
|
-
viewportStart,
|
|
853
|
-
viewportEnd,
|
|
854
|
-
viewTokens,
|
|
855
|
-
layoutHints
|
|
856
|
-
);
|
|
857
|
-
|
|
858
|
-
editor.debug(`buildViewTransform: submit result = ${success}`);
|
|
859
|
-
}
|
|
860
|
-
|
|
861
1009
|
// Check if a file is a markdown file
|
|
862
1010
|
function isMarkdownFile(path: string): boolean {
|
|
863
1011
|
return path.endsWith('.md') || path.endsWith('.markdown');
|
|
864
1012
|
}
|
|
865
1013
|
|
|
866
|
-
// Process a buffer in compose mode
|
|
867
|
-
|
|
1014
|
+
// Process a buffer in compose mode - just enables compose mode
|
|
1015
|
+
// The actual transform happens via view_transform_request hook
|
|
1016
|
+
function processBuffer(bufferId: number, _splitId?: number): void {
|
|
868
1017
|
if (!composeBuffers.has(bufferId)) return;
|
|
869
1018
|
|
|
870
1019
|
const info = editor.getBufferInfo(bufferId);
|
|
871
1020
|
if (!info || !isMarkdownFile(info.path)) return;
|
|
872
1021
|
|
|
873
|
-
editor.debug(`processBuffer:
|
|
874
|
-
|
|
875
|
-
const bufferLength = editor.getBufferLength(bufferId);
|
|
876
|
-
const text = editor.getBufferText(bufferId, 0, bufferLength);
|
|
877
|
-
const parser = new MarkdownParser(text);
|
|
878
|
-
const tokens = parser.parse();
|
|
879
|
-
|
|
880
|
-
// Apply styling with overlays
|
|
881
|
-
applyMarkdownStyling(bufferId, tokens);
|
|
1022
|
+
editor.debug(`processBuffer: enabling compose mode for ${info.path}, buffer_id=${bufferId}`);
|
|
882
1023
|
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
if (!viewport) {
|
|
886
|
-
const viewportStart = 0;
|
|
887
|
-
const viewportEnd = text.length;
|
|
888
|
-
buildViewTransform(bufferId, splitId || null, text, viewportStart, viewportEnd, tokens);
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
const viewportStart = Math.max(0, viewport.top_byte - 500);
|
|
893
|
-
const viewportEnd = Math.min(text.length, viewport.top_byte + (viewport.height * 200));
|
|
894
|
-
buildViewTransform(bufferId, splitId || null, text, viewportStart, viewportEnd, tokens);
|
|
1024
|
+
// Trigger a refresh to get the view_transform_request hook called
|
|
1025
|
+
editor.refreshLines(bufferId);
|
|
895
1026
|
}
|
|
896
1027
|
|
|
897
1028
|
// Enable highlighting for a markdown buffer (auto on file open)
|
|
@@ -965,6 +1096,152 @@ globalThis.markdownToggleCompose = function(): void {
|
|
|
965
1096
|
}
|
|
966
1097
|
};
|
|
967
1098
|
|
|
1099
|
+
/**
|
|
1100
|
+
* Extract text content from incoming tokens
|
|
1101
|
+
* Reconstructs the source text from ViewTokenWire tokens
|
|
1102
|
+
*/
|
|
1103
|
+
function extractTextFromTokens(tokens: ViewTokenWire[]): string {
|
|
1104
|
+
let text = '';
|
|
1105
|
+
for (const token of tokens) {
|
|
1106
|
+
const kind = token.kind;
|
|
1107
|
+
if (kind === "Newline") {
|
|
1108
|
+
text += '\n';
|
|
1109
|
+
} else if (kind === "Space") {
|
|
1110
|
+
text += ' ';
|
|
1111
|
+
} else if (kind === "Break") {
|
|
1112
|
+
// Soft break, ignore for text extraction
|
|
1113
|
+
} else if (typeof kind === 'object' && 'Text' in kind) {
|
|
1114
|
+
text += kind.Text;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return text;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Transform tokens for markdown compose mode with hanging indents
|
|
1122
|
+
*
|
|
1123
|
+
* Strategy: Parse the source text to identify block structure, then walk through
|
|
1124
|
+
* incoming tokens and emit transformed tokens with soft wraps and hanging indents.
|
|
1125
|
+
*/
|
|
1126
|
+
function transformMarkdownTokens(
|
|
1127
|
+
inputTokens: ViewTokenWire[],
|
|
1128
|
+
width: number,
|
|
1129
|
+
viewportStart: number
|
|
1130
|
+
): ViewTokenWire[] {
|
|
1131
|
+
// First, extract text to understand block structure
|
|
1132
|
+
const text = extractTextFromTokens(inputTokens);
|
|
1133
|
+
const blocks = parseMarkdownBlocks(text);
|
|
1134
|
+
|
|
1135
|
+
// Build a map of source_offset -> block info for quick lookup
|
|
1136
|
+
// Block byte positions are 0-based within extracted text
|
|
1137
|
+
// Source offsets are actual buffer positions (viewportStart + position_in_text)
|
|
1138
|
+
const offsetToBlock = new Map<number, ParsedBlock>();
|
|
1139
|
+
for (const block of blocks) {
|
|
1140
|
+
// Map byte positions that fall within this block to the block
|
|
1141
|
+
// contentStartByte and endByte are positions within extracted text (0-based)
|
|
1142
|
+
// source_offset = viewportStart + position_in_extracted_text
|
|
1143
|
+
for (let textPos = block.startByte; textPos < block.endByte; textPos++) {
|
|
1144
|
+
const sourceOffset = viewportStart + textPos;
|
|
1145
|
+
offsetToBlock.set(sourceOffset, block);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const outputTokens: ViewTokenWire[] = [];
|
|
1150
|
+
let column = 0; // Current column position
|
|
1151
|
+
let currentBlock: ParsedBlock | null = null;
|
|
1152
|
+
let lineStarted = false; // Have we output anything on current line?
|
|
1153
|
+
|
|
1154
|
+
for (let i = 0; i < inputTokens.length; i++) {
|
|
1155
|
+
const token = inputTokens[i];
|
|
1156
|
+
const kind = token.kind;
|
|
1157
|
+
const sourceOffset = token.source_offset;
|
|
1158
|
+
|
|
1159
|
+
// Track which block we're in based on source offset
|
|
1160
|
+
if (sourceOffset !== null) {
|
|
1161
|
+
const block = offsetToBlock.get(sourceOffset);
|
|
1162
|
+
if (block) {
|
|
1163
|
+
currentBlock = block;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Get hanging indent for current block (default 0)
|
|
1168
|
+
const hangingIndent = currentBlock?.hangingIndent ?? 0;
|
|
1169
|
+
|
|
1170
|
+
// Handle different token types
|
|
1171
|
+
if (kind === "Newline") {
|
|
1172
|
+
// Real newlines pass through - they end a block
|
|
1173
|
+
outputTokens.push(token);
|
|
1174
|
+
column = 0;
|
|
1175
|
+
lineStarted = false;
|
|
1176
|
+
currentBlock = null; // Reset at line boundary
|
|
1177
|
+
} else if (kind === "Space") {
|
|
1178
|
+
// Space handling - potentially wrap before space + next word
|
|
1179
|
+
if (!lineStarted) {
|
|
1180
|
+
// Leading space on a line - preserve it
|
|
1181
|
+
outputTokens.push(token);
|
|
1182
|
+
column++;
|
|
1183
|
+
lineStarted = true;
|
|
1184
|
+
} else {
|
|
1185
|
+
// Mid-line space - look ahead to see if we need to wrap
|
|
1186
|
+
// Find next non-space token to check word length
|
|
1187
|
+
let nextWordLen = 0;
|
|
1188
|
+
for (let j = i + 1; j < inputTokens.length; j++) {
|
|
1189
|
+
const nextKind = inputTokens[j].kind;
|
|
1190
|
+
if (nextKind === "Space" || nextKind === "Newline" || nextKind === "Break") {
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
if (typeof nextKind === 'object' && 'Text' in nextKind) {
|
|
1194
|
+
nextWordLen += nextKind.Text.length;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Check if space + next word would exceed width
|
|
1199
|
+
if (column + 1 + nextWordLen > width && nextWordLen > 0) {
|
|
1200
|
+
// Wrap: emit soft newline + hanging indent instead of space
|
|
1201
|
+
outputTokens.push({ source_offset: null, kind: "Newline" });
|
|
1202
|
+
for (let j = 0; j < hangingIndent; j++) {
|
|
1203
|
+
outputTokens.push({ source_offset: null, kind: "Space" });
|
|
1204
|
+
}
|
|
1205
|
+
column = hangingIndent;
|
|
1206
|
+
// Don't emit the space - we wrapped instead
|
|
1207
|
+
} else {
|
|
1208
|
+
// No wrap needed - emit the space normally
|
|
1209
|
+
outputTokens.push(token);
|
|
1210
|
+
column++;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
} else if (kind === "Break") {
|
|
1214
|
+
// Existing soft breaks - we're replacing wrapping logic, so skip these
|
|
1215
|
+
// and handle wrapping ourselves
|
|
1216
|
+
} else if (typeof kind === 'object' && 'Text' in kind) {
|
|
1217
|
+
const text = kind.Text;
|
|
1218
|
+
|
|
1219
|
+
if (!lineStarted) {
|
|
1220
|
+
lineStarted = true;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Check if this word alone would exceed width (need to wrap)
|
|
1224
|
+
if (column > hangingIndent && column + text.length > width) {
|
|
1225
|
+
// Wrap before this word
|
|
1226
|
+
outputTokens.push({ source_offset: null, kind: "Newline" });
|
|
1227
|
+
for (let j = 0; j < hangingIndent; j++) {
|
|
1228
|
+
outputTokens.push({ source_offset: null, kind: "Space" });
|
|
1229
|
+
}
|
|
1230
|
+
column = hangingIndent;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Emit the text token
|
|
1234
|
+
outputTokens.push(token);
|
|
1235
|
+
column += text.length;
|
|
1236
|
+
} else {
|
|
1237
|
+
// Unknown token type - pass through
|
|
1238
|
+
outputTokens.push(token);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return outputTokens;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
968
1245
|
// Handle view transform request - receives tokens from core for transformation
|
|
969
1246
|
// Only applies transforms when in compose mode (not just highlighting)
|
|
970
1247
|
globalThis.onMarkdownViewTransform = function(data: {
|
|
@@ -982,36 +1259,26 @@ globalThis.onMarkdownViewTransform = function(data: {
|
|
|
982
1259
|
|
|
983
1260
|
editor.debug(`onMarkdownViewTransform: buffer=${data.buffer_id}, split=${data.split_id}, tokens=${data.tokens.length}`);
|
|
984
1261
|
|
|
985
|
-
//
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
reconstructedText += '\n';
|
|
992
|
-
} else if (token.kind === 'Space') {
|
|
993
|
-
reconstructedText += ' ';
|
|
994
|
-
}
|
|
995
|
-
}
|
|
1262
|
+
// Transform the incoming tokens with markdown-aware wrapping
|
|
1263
|
+
const transformedTokens = transformMarkdownTokens(
|
|
1264
|
+
data.tokens,
|
|
1265
|
+
config.composeWidth,
|
|
1266
|
+
data.viewport_start
|
|
1267
|
+
);
|
|
996
1268
|
|
|
997
|
-
//
|
|
998
|
-
const
|
|
1269
|
+
// Extract text for overlay styling
|
|
1270
|
+
const text = extractTextFromTokens(data.tokens);
|
|
1271
|
+
const parser = new MarkdownParser(text);
|
|
999
1272
|
const mdTokens = parser.parse();
|
|
1000
1273
|
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
}));
|
|
1008
|
-
applyMarkdownStyling(data.buffer_id, offsetTokens);
|
|
1009
|
-
|
|
1010
|
-
// Transform the view tokens based on markdown structure
|
|
1011
|
-
// Convert newlines to spaces for soft breaks (paragraphs)
|
|
1012
|
-
const transformedTokens = transformTokensForMarkdown(data.tokens, mdTokens, data.viewport_start);
|
|
1274
|
+
// Adjust token offsets for viewport
|
|
1275
|
+
for (const token of mdTokens) {
|
|
1276
|
+
token.start += data.viewport_start;
|
|
1277
|
+
token.end += data.viewport_start;
|
|
1278
|
+
}
|
|
1279
|
+
applyMarkdownStyling(data.buffer_id, mdTokens);
|
|
1013
1280
|
|
|
1014
|
-
// Submit the transformed tokens
|
|
1281
|
+
// Submit the transformed tokens - keep compose_width for margins/centering
|
|
1015
1282
|
const layoutHints: LayoutHints = {
|
|
1016
1283
|
compose_width: config.composeWidth,
|
|
1017
1284
|
column_guides: null,
|
|
@@ -1027,74 +1294,6 @@ globalThis.onMarkdownViewTransform = function(data: {
|
|
|
1027
1294
|
);
|
|
1028
1295
|
};
|
|
1029
1296
|
|
|
1030
|
-
// Transform view tokens based on markdown structure
|
|
1031
|
-
function transformTokensForMarkdown(
|
|
1032
|
-
tokens: ViewTokenWire[],
|
|
1033
|
-
mdTokens: Token[],
|
|
1034
|
-
viewportStart: number
|
|
1035
|
-
): ViewTokenWire[] {
|
|
1036
|
-
const result: ViewTokenWire[] = [];
|
|
1037
|
-
|
|
1038
|
-
// Build a set of positions that should have hard breaks
|
|
1039
|
-
const hardBreakPositions = new Set<number>();
|
|
1040
|
-
for (const t of mdTokens) {
|
|
1041
|
-
if (t.type === TokenType.HardBreak ||
|
|
1042
|
-
t.type === TokenType.Header1 ||
|
|
1043
|
-
t.type === TokenType.Header2 ||
|
|
1044
|
-
t.type === TokenType.Header3 ||
|
|
1045
|
-
t.type === TokenType.Header4 ||
|
|
1046
|
-
t.type === TokenType.Header5 ||
|
|
1047
|
-
t.type === TokenType.Header6 ||
|
|
1048
|
-
t.type === TokenType.ListItem ||
|
|
1049
|
-
t.type === TokenType.OrderedListItem ||
|
|
1050
|
-
t.type === TokenType.Checkbox ||
|
|
1051
|
-
t.type === TokenType.CodeBlockFence ||
|
|
1052
|
-
t.type === TokenType.CodeBlockContent ||
|
|
1053
|
-
t.type === TokenType.BlockQuote ||
|
|
1054
|
-
t.type === TokenType.HorizontalRule ||
|
|
1055
|
-
t.type === TokenType.Image) {
|
|
1056
|
-
// Mark the end of these elements as hard breaks
|
|
1057
|
-
hardBreakPositions.add(t.end + viewportStart);
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
// Also mark empty lines (two consecutive newlines) as hard breaks
|
|
1062
|
-
let lastWasNewline = false;
|
|
1063
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
1064
|
-
const token = tokens[i];
|
|
1065
|
-
if (token.kind === 'Newline') {
|
|
1066
|
-
if (lastWasNewline && token.source_offset !== null) {
|
|
1067
|
-
hardBreakPositions.add(token.source_offset);
|
|
1068
|
-
}
|
|
1069
|
-
lastWasNewline = true;
|
|
1070
|
-
} else {
|
|
1071
|
-
lastWasNewline = false;
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// Transform tokens
|
|
1076
|
-
for (const token of tokens) {
|
|
1077
|
-
if (token.kind === 'Newline') {
|
|
1078
|
-
const pos = token.source_offset;
|
|
1079
|
-
if (pos !== null && hardBreakPositions.has(pos)) {
|
|
1080
|
-
// Keep as newline (hard break)
|
|
1081
|
-
result.push(token);
|
|
1082
|
-
} else {
|
|
1083
|
-
// Convert to space (soft break)
|
|
1084
|
-
result.push({
|
|
1085
|
-
source_offset: token.source_offset,
|
|
1086
|
-
kind: 'Space',
|
|
1087
|
-
});
|
|
1088
|
-
}
|
|
1089
|
-
} else {
|
|
1090
|
-
// Keep other tokens as-is
|
|
1091
|
-
result.push(token);
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
return result;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
1297
|
// Handle render_start - enable highlighting for markdown files
|
|
1099
1298
|
globalThis.onMarkdownRenderStart = function(data: { buffer_id: number }): void {
|
|
1100
1299
|
// Auto-enable highlighting for markdown files on first render
|
|
@@ -1193,8 +1392,42 @@ editor.on("buffer_activated", "onMarkdownBufferActivated");
|
|
|
1193
1392
|
editor.on("after-insert", "onMarkdownAfterInsert");
|
|
1194
1393
|
editor.on("after-delete", "onMarkdownAfterDelete");
|
|
1195
1394
|
editor.on("buffer_closed", "onMarkdownBufferClosed");
|
|
1395
|
+
editor.on("prompt_confirmed", "onMarkdownComposeWidthConfirmed");
|
|
1396
|
+
|
|
1397
|
+
// Set compose width command - starts interactive prompt
|
|
1398
|
+
globalThis.markdownSetComposeWidth = function(): void {
|
|
1399
|
+
editor.startPrompt("Compose width: ", "markdown-compose-width");
|
|
1400
|
+
editor.setPromptSuggestions([
|
|
1401
|
+
{ text: "60", description: "Narrow - good for side panels" },
|
|
1402
|
+
{ text: "72", description: "Classic - traditional terminal width" },
|
|
1403
|
+
{ text: "80", description: "Standard - default width" },
|
|
1404
|
+
{ text: "100", description: "Wide - more content per line" },
|
|
1405
|
+
]);
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
// Handle compose width prompt confirmation
|
|
1409
|
+
globalThis.onMarkdownComposeWidthConfirmed = function(args: {
|
|
1410
|
+
prompt_type: string;
|
|
1411
|
+
text: string;
|
|
1412
|
+
}): void {
|
|
1413
|
+
if (args.prompt_type !== "markdown-compose-width") return;
|
|
1414
|
+
|
|
1415
|
+
const width = parseInt(args.text, 10);
|
|
1416
|
+
if (!isNaN(width) && width > 20 && width < 300) {
|
|
1417
|
+
config.composeWidth = width;
|
|
1418
|
+
editor.setStatus(`Markdown compose width set to ${width}`);
|
|
1196
1419
|
|
|
1197
|
-
//
|
|
1420
|
+
// Re-process active buffer if in compose mode
|
|
1421
|
+
const bufferId = editor.getActiveBufferId();
|
|
1422
|
+
if (composeBuffers.has(bufferId)) {
|
|
1423
|
+
editor.refreshLines(bufferId); // Trigger re-transform
|
|
1424
|
+
}
|
|
1425
|
+
} else {
|
|
1426
|
+
editor.setStatus("Invalid width - must be between 20 and 300");
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
// Register commands
|
|
1198
1431
|
editor.registerCommand(
|
|
1199
1432
|
"Markdown: Toggle Compose",
|
|
1200
1433
|
"Toggle beautiful Markdown rendering (soft breaks, syntax highlighting)",
|
|
@@ -1202,6 +1435,13 @@ editor.registerCommand(
|
|
|
1202
1435
|
"normal"
|
|
1203
1436
|
);
|
|
1204
1437
|
|
|
1438
|
+
editor.registerCommand(
|
|
1439
|
+
"Markdown: Set Compose Width",
|
|
1440
|
+
"Set the width for compose mode wrapping and margins",
|
|
1441
|
+
"markdownSetComposeWidth",
|
|
1442
|
+
"normal"
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1205
1445
|
// Initialization
|
|
1206
1446
|
editor.debug("Markdown Compose plugin loaded - use 'Markdown: Toggle Compose' command");
|
|
1207
1447
|
editor.setStatus("Markdown plugin ready");
|