@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.
@@ -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: ![alt](url)
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 (highlighting + view transform)
867
- function processBuffer(bufferId: number, splitId?: number): void {
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: processing ${info.path}, buffer_id=${bufferId}`);
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
- // Get viewport info and build view transform
884
- const viewport = editor.getViewport();
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
- // Reconstruct text from tokens for parsing (we need text for markdown parsing)
986
- let reconstructedText = '';
987
- for (const token of data.tokens) {
988
- if (typeof token.kind === 'object' && 'Text' in token.kind) {
989
- reconstructedText += token.kind.Text;
990
- } else if (token.kind === 'Newline') {
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
- // Parse markdown from reconstructed text
998
- const parser = new MarkdownParser(reconstructedText);
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
- // Apply overlays for styling (this still works via the existing overlay API)
1002
- // Offset the markdown tokens by viewport_start for correct positioning
1003
- const offsetTokens = mdTokens.map(t => ({
1004
- ...t,
1005
- start: t.start + data.viewport_start,
1006
- end: t.end + data.viewport_start,
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
- // Register command
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");