@fresh-editor/fresh-editor 0.2.2 → 0.2.4
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/CHANGELOG.md +67 -0
- package/README.md +32 -29
- package/package.json +1 -1
- package/plugins/config-schema.json +50 -0
- package/plugins/lib/fresh.d.ts +153 -62
- package/plugins/markdown_compose.i18n.json +42 -56
- package/plugins/markdown_compose.ts +1050 -79
- package/plugins/pkg.ts +135 -8
- package/plugins/schemas/package.schema.json +5 -0
- package/plugins/schemas/theme.schema.json +9 -0
- package/plugins/theme_editor.i18n.json +28 -0
|
@@ -11,19 +11,74 @@ const editor = getEditor();
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
interface MarkdownConfig {
|
|
14
|
-
composeWidth: number;
|
|
14
|
+
composeWidth: number | null;
|
|
15
15
|
maxWidth: number;
|
|
16
16
|
hideLineNumbers: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const config: MarkdownConfig = {
|
|
20
|
-
composeWidth:
|
|
20
|
+
composeWidth: null,
|
|
21
21
|
maxWidth: 100,
|
|
22
22
|
hideLineNumbers: true,
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
//
|
|
26
|
-
|
|
25
|
+
// Table column widths stored per-buffer-per-split via setViewState/getViewState.
|
|
26
|
+
// Persisted across sessions and independent per split.
|
|
27
|
+
interface TableWidthInfo {
|
|
28
|
+
maxW: number[];
|
|
29
|
+
allocated: number[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Helper: check whether the active split has compose mode for this buffer
|
|
33
|
+
function isComposing(bufferId: number): boolean {
|
|
34
|
+
const info = editor.getBufferInfo(bufferId);
|
|
35
|
+
return info != null && info.view_mode === "compose";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helper: check whether ANY split showing this buffer has compose mode.
|
|
39
|
+
// Use this for decoration maintenance (conceals, soft breaks, overlays) since
|
|
40
|
+
// decorations live on the buffer and are filtered per-split at render time.
|
|
41
|
+
function isComposingInAnySplit(bufferId: number): boolean {
|
|
42
|
+
const info = editor.getBufferInfo(bufferId);
|
|
43
|
+
return info != null && info.is_composing_in_any_split;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Helper: get cached table column widths from per-buffer-per-split view state
|
|
47
|
+
function getTableWidths(bufferId: number): Map<number, TableWidthInfo> | undefined {
|
|
48
|
+
const obj = editor.getViewState(bufferId, "table-widths") as Record<string, { maxW: number[]; allocated: number[] }> | undefined;
|
|
49
|
+
if (!obj || typeof obj !== "object") return undefined;
|
|
50
|
+
const map = new Map<number, TableWidthInfo>();
|
|
51
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
52
|
+
map.set(parseInt(k, 10), v);
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Helper: store cached table column widths in per-buffer-per-split view state
|
|
58
|
+
function setTableWidths(bufferId: number, widthMap: Map<number, TableWidthInfo>): void {
|
|
59
|
+
const obj: Record<string, TableWidthInfo> = {};
|
|
60
|
+
for (const [k, v] of widthMap) {
|
|
61
|
+
obj[String(k)] = v;
|
|
62
|
+
}
|
|
63
|
+
editor.setViewState(bufferId, "table-widths", obj);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Helper: clear cached table column widths
|
|
67
|
+
function clearTableWidths(bufferId: number): void {
|
|
68
|
+
editor.setViewState(bufferId, "table-widths", null);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Static map of named HTML entities to their Unicode replacements
|
|
72
|
+
const HTML_ENTITY_MAP: Record<string, string> = {
|
|
73
|
+
nbsp: "\u00A0", amp: "&", lt: "<", gt: ">", mdash: "\u2014", ndash: "\u2013",
|
|
74
|
+
hellip: "\u2026", rsquo: "\u2019", lsquo: "\u2018", rdquo: "\u201D", ldquo: "\u201C",
|
|
75
|
+
bull: "\u2022", middot: "\u00B7", copy: "\u00A9", reg: "\u00AE", trade: "\u2122",
|
|
76
|
+
times: "\u00D7", divide: "\u00F7", plusmn: "\u00B1", deg: "\u00B0",
|
|
77
|
+
frac12: "\u00BD", frac14: "\u00BC", rarr: "\u2192", larr: "\u2190",
|
|
78
|
+
harr: "\u2194", uarr: "\u2191", darr: "\u2193", euro: "\u20AC", pound: "\u00A3",
|
|
79
|
+
yen: "\u00A5", cent: "\u00A2", sect: "\u00A7", para: "\u00B6",
|
|
80
|
+
laquo: "\u00AB", raquo: "\u00BB", ensp: "\u2002", emsp: "\u2003", thinsp: "\u2009",
|
|
81
|
+
};
|
|
27
82
|
|
|
28
83
|
// =============================================================================
|
|
29
84
|
// Block-based parser for hanging indent support
|
|
@@ -31,7 +86,8 @@ const composeBuffers = new Set<number>();
|
|
|
31
86
|
|
|
32
87
|
interface ParsedBlock {
|
|
33
88
|
type: 'paragraph' | 'list-item' | 'ordered-list' | 'checkbox' | 'blockquote' |
|
|
34
|
-
'heading' | 'code-fence' | 'code-content' | 'hr' | 'empty' | 'image'
|
|
89
|
+
'heading' | 'code-fence' | 'code-content' | 'hr' | 'empty' | 'image' |
|
|
90
|
+
'table-row';
|
|
35
91
|
startByte: number; // First byte of the line
|
|
36
92
|
endByte: number; // Byte after last char (before newline)
|
|
37
93
|
leadingIndent: number; // Spaces before marker/content
|
|
@@ -267,6 +323,24 @@ function parseMarkdownBlocks(text: string): ParsedBlock[] {
|
|
|
267
323
|
continue;
|
|
268
324
|
}
|
|
269
325
|
|
|
326
|
+
// Table row: | cell | cell | or separator |---|---|
|
|
327
|
+
if (trimmed.startsWith('|') || trimmed.endsWith('|')) {
|
|
328
|
+
blocks.push({
|
|
329
|
+
type: 'table-row',
|
|
330
|
+
startByte: lineStart,
|
|
331
|
+
endByte: lineEnd,
|
|
332
|
+
leadingIndent: line.length - line.trimStart().length,
|
|
333
|
+
marker: '',
|
|
334
|
+
markerStartByte: lineStart,
|
|
335
|
+
contentStartByte: lineStart,
|
|
336
|
+
content: line,
|
|
337
|
+
hangingIndent: 0,
|
|
338
|
+
forceHardBreak: true,
|
|
339
|
+
});
|
|
340
|
+
byteOffset = lineEnd + 1;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
270
344
|
// Hard break (trailing spaces or backslash)
|
|
271
345
|
const hasHardBreak = line.endsWith(' ') || line.endsWith('\\');
|
|
272
346
|
|
|
@@ -295,46 +369,51 @@ function isMarkdownFile(path: string): boolean {
|
|
|
295
369
|
return path.endsWith('.md') || path.endsWith('.markdown');
|
|
296
370
|
}
|
|
297
371
|
|
|
298
|
-
// Process a buffer in compose mode - just enables compose mode
|
|
299
|
-
// The actual transform happens via view_transform_request hook
|
|
300
|
-
function processBuffer(bufferId: number, _splitId?: number): void {
|
|
301
|
-
if (!composeBuffers.has(bufferId)) return;
|
|
302
372
|
|
|
373
|
+
// Enable full compose mode for a buffer (explicit toggle or restore from session).
|
|
374
|
+
// Idempotent: safe to call when already in compose mode (re-applies line numbers,
|
|
375
|
+
// line wrap, and layout hints — needed after session restore where Rust already has
|
|
376
|
+
// ViewMode::Compose but the plugin hasn't applied its settings yet).
|
|
377
|
+
function enableMarkdownCompose(bufferId: number): void {
|
|
303
378
|
const info = editor.getBufferInfo(bufferId);
|
|
304
379
|
if (!info || !isMarkdownFile(info.path)) return;
|
|
305
380
|
|
|
306
|
-
|
|
381
|
+
// Tell Rust side this buffer is in compose mode (idempotent)
|
|
382
|
+
editor.setViewMode(bufferId, "compose");
|
|
307
383
|
|
|
308
|
-
//
|
|
309
|
-
editor.
|
|
310
|
-
}
|
|
384
|
+
// Hide line numbers in compose mode
|
|
385
|
+
editor.setLineNumbers(bufferId, false);
|
|
311
386
|
|
|
312
|
-
// Enable
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
387
|
+
// Enable native line wrapping so that long lines without whitespace
|
|
388
|
+
// (which the plugin can't soft-break) are force-wrapped by the Rust
|
|
389
|
+
// wrapping transform at the content width.
|
|
390
|
+
editor.setLineWrap(bufferId, null, true);
|
|
316
391
|
|
|
317
|
-
|
|
318
|
-
|
|
392
|
+
// Set layout hints for centered margins
|
|
393
|
+
editor.setLayoutHints(bufferId, null, { composeWidth: config.composeWidth });
|
|
319
394
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
processBuffer(bufferId);
|
|
324
|
-
editor.debug(`Markdown compose enabled for buffer ${bufferId}`);
|
|
325
|
-
}
|
|
395
|
+
// Trigger a refresh so lines_changed hooks fire for visible content
|
|
396
|
+
editor.refreshLines(bufferId);
|
|
397
|
+
editor.debug(`Markdown compose enabled for buffer ${bufferId}`);
|
|
326
398
|
}
|
|
327
399
|
|
|
328
400
|
// Disable compose mode for a buffer
|
|
329
401
|
function disableMarkdownCompose(bufferId: number): void {
|
|
330
|
-
if (
|
|
331
|
-
|
|
402
|
+
if (isComposing(bufferId)) {
|
|
403
|
+
editor.setViewState(bufferId, "last-cursor-line", null);
|
|
404
|
+
clearTableWidths(bufferId);
|
|
405
|
+
|
|
406
|
+
// Tell Rust side this buffer is back in source mode
|
|
407
|
+
editor.setViewMode(bufferId, "source");
|
|
332
408
|
|
|
333
409
|
// Re-enable line numbers
|
|
334
410
|
editor.setLineNumbers(bufferId, true);
|
|
335
411
|
|
|
336
|
-
// Clear
|
|
337
|
-
editor.
|
|
412
|
+
// Clear layout hints, emphasis overlays, conceals, and soft breaks
|
|
413
|
+
editor.setLayoutHints(bufferId, null, {});
|
|
414
|
+
editor.clearNamespace(bufferId, "md-emphasis");
|
|
415
|
+
editor.clearConcealNamespace(bufferId, "md-syntax");
|
|
416
|
+
editor.clearSoftBreakNamespace(bufferId, "md-wrap");
|
|
338
417
|
|
|
339
418
|
editor.refreshLines(bufferId);
|
|
340
419
|
editor.debug(`Markdown compose disabled for buffer ${bufferId}`);
|
|
@@ -354,7 +433,7 @@ globalThis.markdownToggleCompose = function(): void {
|
|
|
354
433
|
return;
|
|
355
434
|
}
|
|
356
435
|
|
|
357
|
-
if (
|
|
436
|
+
if (isComposing(bufferId)) {
|
|
358
437
|
disableMarkdownCompose(bufferId);
|
|
359
438
|
editor.setStatus(editor.t("status.compose_off"));
|
|
360
439
|
} else {
|
|
@@ -436,6 +515,13 @@ function transformMarkdownTokens(
|
|
|
436
515
|
// Get hanging indent for current block (default 0)
|
|
437
516
|
const hangingIndent = currentBlock?.hangingIndent ?? 0;
|
|
438
517
|
|
|
518
|
+
// Determine if current block should be soft-wrapped
|
|
519
|
+
const blockType = currentBlock?.type;
|
|
520
|
+
const noWrap = blockType === 'table-row' || blockType === 'code-fence' ||
|
|
521
|
+
blockType === 'code-content' || blockType === 'hr' ||
|
|
522
|
+
blockType === 'heading' || blockType === 'image' ||
|
|
523
|
+
blockType === 'empty';
|
|
524
|
+
|
|
439
525
|
// Handle different token types
|
|
440
526
|
if (kind === "Newline") {
|
|
441
527
|
// Real newlines pass through - they end a block
|
|
@@ -465,7 +551,7 @@ function transformMarkdownTokens(
|
|
|
465
551
|
}
|
|
466
552
|
|
|
467
553
|
// Check if space + next word would exceed width
|
|
468
|
-
if (column + 1 + nextWordLen > width && nextWordLen > 0) {
|
|
554
|
+
if (!noWrap && column + 1 + nextWordLen > width && nextWordLen > 0) {
|
|
469
555
|
// Wrap: emit soft newline + hanging indent instead of space
|
|
470
556
|
outputTokens.push({ source_offset: null, kind: "Newline" });
|
|
471
557
|
for (let j = 0; j < hangingIndent; j++) {
|
|
@@ -490,7 +576,7 @@ function transformMarkdownTokens(
|
|
|
490
576
|
}
|
|
491
577
|
|
|
492
578
|
// Check if this word alone would exceed width (need to wrap)
|
|
493
|
-
if (column > hangingIndent && column + text.length > width) {
|
|
579
|
+
if (!noWrap && column > hangingIndent && column + text.length > width) {
|
|
494
580
|
// Wrap before this word
|
|
495
581
|
outputTokens.push({ source_offset: null, kind: "Newline" });
|
|
496
582
|
for (let j = 0; j < hangingIndent; j++) {
|
|
@@ -511,83 +597,968 @@ function transformMarkdownTokens(
|
|
|
511
597
|
return outputTokens;
|
|
512
598
|
}
|
|
513
599
|
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
|
|
600
|
+
// =============================================================================
|
|
601
|
+
// Line-level conceal/overlay processing
|
|
602
|
+
// =============================================================================
|
|
603
|
+
// Conceals and overlays are managed per-line using targeted range-based clearing.
|
|
604
|
+
// The lines_changed hook processes newly visible or edited lines.
|
|
605
|
+
// The after_insert/after_delete hooks clear affected byte ranges.
|
|
606
|
+
// The view_transform_request hook handles cursor-aware reveal/conceal updates
|
|
607
|
+
// and soft wrapping.
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Convert a char offset within lineContent to a buffer byte offset.
|
|
611
|
+
* Handles UTF-8 multi-byte characters correctly.
|
|
612
|
+
*/
|
|
613
|
+
function charToByte(lineContent: string, charOffset: number, lineByteStart: number): number {
|
|
614
|
+
return lineByteStart + editor.utf8ByteLength(lineContent.slice(0, charOffset));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// Shared inline span detection — used by both processLineConceals (to apply
|
|
619
|
+
// conceals + overlays) and concealedText (to compute visible table widths).
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
interface InlineSpan {
|
|
623
|
+
type: 'code' | 'bold-italic' | 'bold' | 'italic' | 'strikethrough' | 'link' | 'entity';
|
|
624
|
+
matchStart: number; // char offset of full match start
|
|
625
|
+
matchEnd: number; // char offset of full match end
|
|
626
|
+
contentStart: number; // char offset of visible content start
|
|
627
|
+
contentEnd: number; // char offset of visible content end
|
|
628
|
+
concealRanges: Array<{start: number; end: number; replacement: string | null}>;
|
|
629
|
+
linkUrl?: string;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Find all inline spans that would produce conceals in the given text. */
|
|
633
|
+
function findInlineSpans(text: string): InlineSpan[] {
|
|
634
|
+
const spans: InlineSpan[] = [];
|
|
635
|
+
let m: RegExpExecArray | null;
|
|
636
|
+
|
|
637
|
+
// 1. Code spans (also builds exclusion set)
|
|
638
|
+
const codeSpanCharRanges: [number, number][] = [];
|
|
639
|
+
const codeRe = /(?<!`)(`)((?:[^`]|(?<=\\)`)+)\1(?!`)/g;
|
|
640
|
+
while ((m = codeRe.exec(text)) !== null) {
|
|
641
|
+
const ms = m.index;
|
|
642
|
+
const me = ms + m[0].length;
|
|
643
|
+
codeSpanCharRanges.push([ms, me]);
|
|
644
|
+
spans.push({
|
|
645
|
+
type: 'code',
|
|
646
|
+
matchStart: ms, matchEnd: me,
|
|
647
|
+
contentStart: ms + 1, contentEnd: me - 1,
|
|
648
|
+
concealRanges: [
|
|
649
|
+
{ start: ms, end: ms + 1, replacement: null },
|
|
650
|
+
{ start: me - 1, end: me, replacement: null },
|
|
651
|
+
],
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function inCodeSpan(charPos: number): boolean {
|
|
656
|
+
for (const [s, e] of codeSpanCharRanges) {
|
|
657
|
+
if (charPos >= s && charPos < e) return true;
|
|
658
|
+
}
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// 2. Emphasis
|
|
663
|
+
const emphasisPatterns: [RegExp, InlineSpan['type'], number][] = [
|
|
664
|
+
[/\*{3}([^*]+)\*{3}/g, 'bold-italic', 3],
|
|
665
|
+
[/(?<!\*)\*{2}(?!\*)([^*]+?)(?<!\*)\*{2}(?!\*)/g, 'bold', 2],
|
|
666
|
+
[/(?<!\*)\*(?!\*)([^*]+?)(?<!\*)\*(?!\*)/g, 'italic', 1],
|
|
667
|
+
[/~~([^~]+)~~/g, 'strikethrough', 2],
|
|
668
|
+
];
|
|
669
|
+
for (const [pattern, type, markerLen] of emphasisPatterns) {
|
|
670
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
671
|
+
while ((m = re.exec(text)) !== null) {
|
|
672
|
+
if (inCodeSpan(m.index)) continue;
|
|
673
|
+
const ms = m.index;
|
|
674
|
+
const me = ms + m[0].length;
|
|
675
|
+
spans.push({
|
|
676
|
+
type,
|
|
677
|
+
matchStart: ms, matchEnd: me,
|
|
678
|
+
contentStart: ms + markerLen,
|
|
679
|
+
contentEnd: ms + markerLen + m[1].length,
|
|
680
|
+
concealRanges: [
|
|
681
|
+
{ start: ms, end: ms + markerLen, replacement: null },
|
|
682
|
+
{ start: me - markerLen, end: me, replacement: null },
|
|
683
|
+
],
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 3. Links
|
|
689
|
+
const linkRe = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
|
|
690
|
+
while ((m = linkRe.exec(text)) !== null) {
|
|
691
|
+
if (inCodeSpan(m.index)) continue;
|
|
692
|
+
const ms = m.index;
|
|
693
|
+
const me = ms + m[0].length;
|
|
694
|
+
const textEnd = ms + 1 + m[1].length;
|
|
695
|
+
spans.push({
|
|
696
|
+
type: 'link',
|
|
697
|
+
matchStart: ms, matchEnd: me,
|
|
698
|
+
contentStart: ms + 1, contentEnd: textEnd,
|
|
699
|
+
concealRanges: [
|
|
700
|
+
{ start: ms, end: ms + 1, replacement: null },
|
|
701
|
+
{ start: textEnd, end: me, replacement: ` — ${m[2]}` },
|
|
702
|
+
],
|
|
703
|
+
linkUrl: m[2],
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// 4. HTML entities
|
|
708
|
+
const namedEntityRe = /&(nbsp|amp|lt|gt|mdash|ndash|hellip|rsquo|lsquo|rdquo|ldquo|bull|middot|copy|reg|trade|times|divide|plusmn|deg|frac12|frac14|rarr|larr|harr|uarr|darr|euro|pound|yen|cent|sect|para|laquo|raquo|ensp|emsp|thinsp);/g;
|
|
709
|
+
while ((m = namedEntityRe.exec(text)) !== null) {
|
|
710
|
+
if (inCodeSpan(m.index)) continue;
|
|
711
|
+
const replacement = HTML_ENTITY_MAP[m[1]];
|
|
712
|
+
if (!replacement) continue;
|
|
713
|
+
spans.push({
|
|
714
|
+
type: 'entity',
|
|
715
|
+
matchStart: m.index, matchEnd: m.index + m[0].length,
|
|
716
|
+
contentStart: m.index, contentEnd: m.index + m[0].length,
|
|
717
|
+
concealRanges: [{ start: m.index, end: m.index + m[0].length, replacement }],
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
const numericDecEntityRe = /&#(\d{1,6});/g;
|
|
721
|
+
while ((m = numericDecEntityRe.exec(text)) !== null) {
|
|
722
|
+
if (inCodeSpan(m.index)) continue;
|
|
723
|
+
const cp = parseInt(m[1], 10);
|
|
724
|
+
if (cp < 1 || cp > 0x10FFFF) continue;
|
|
725
|
+
spans.push({
|
|
726
|
+
type: 'entity',
|
|
727
|
+
matchStart: m.index, matchEnd: m.index + m[0].length,
|
|
728
|
+
contentStart: m.index, contentEnd: m.index + m[0].length,
|
|
729
|
+
concealRanges: [{ start: m.index, end: m.index + m[0].length, replacement: String.fromCodePoint(cp) }],
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
const numericHexEntityRe = /&#x([0-9a-fA-F]{1,6});/g;
|
|
733
|
+
while ((m = numericHexEntityRe.exec(text)) !== null) {
|
|
734
|
+
if (inCodeSpan(m.index)) continue;
|
|
735
|
+
const cp = parseInt(m[1], 16);
|
|
736
|
+
if (cp < 1 || cp > 0x10FFFF) continue;
|
|
737
|
+
spans.push({
|
|
738
|
+
type: 'entity',
|
|
739
|
+
matchStart: m.index, matchEnd: m.index + m[0].length,
|
|
740
|
+
contentStart: m.index, contentEnd: m.index + m[0].length,
|
|
741
|
+
concealRanges: [{ start: m.index, end: m.index + m[0].length, replacement: String.fromCodePoint(cp) }],
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return spans;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Return the visible text of a string after applying all inline conceals.
|
|
750
|
+
* Used for table column width calculation so emphasis/link syntax is not
|
|
751
|
+
* counted towards cell width.
|
|
752
|
+
*/
|
|
753
|
+
function concealedText(text: string): string {
|
|
754
|
+
const ranges: Array<{start: number; end: number; replacement: string | null}> = [];
|
|
755
|
+
for (const span of findInlineSpans(text)) {
|
|
756
|
+
ranges.push(...span.concealRanges);
|
|
757
|
+
}
|
|
758
|
+
ranges.sort((a, b) => a.start - b.start);
|
|
759
|
+
|
|
760
|
+
let result = '';
|
|
761
|
+
let pos = 0;
|
|
762
|
+
for (const r of ranges) {
|
|
763
|
+
if (r.start < pos) continue; // overlapping range
|
|
764
|
+
if (r.start > pos) result += text.slice(pos, r.start);
|
|
765
|
+
if (r.replacement !== null) result += r.replacement;
|
|
766
|
+
pos = r.end;
|
|
767
|
+
}
|
|
768
|
+
result += text.slice(pos);
|
|
769
|
+
return result;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const MIN_COL_W = 3;
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* W3C-inspired column width distribution.
|
|
776
|
+
* Constrains columns to fit within `available` width, distributing space
|
|
777
|
+
* proportionally to each column's natural (max) width.
|
|
778
|
+
*/
|
|
779
|
+
function distributeColumnWidths(maxW: number[], available: number): number[] {
|
|
780
|
+
const numCols = maxW.length;
|
|
781
|
+
const total = maxW.reduce((s, w) => s + w, 0);
|
|
782
|
+
if (total <= available) return maxW;
|
|
783
|
+
if (numCols * MIN_COL_W >= available) return maxW.map(() => MIN_COL_W);
|
|
784
|
+
|
|
785
|
+
const remaining = available - numCols * MIN_COL_W;
|
|
786
|
+
const excess = maxW.reduce((s, w) => s + Math.max(0, w - MIN_COL_W), 0);
|
|
787
|
+
return maxW.map(w => {
|
|
788
|
+
const extra = excess > 0 ? Math.floor(remaining * Math.max(0, w - MIN_COL_W) / excess) : 0;
|
|
789
|
+
return MIN_COL_W + extra;
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Wrap text into lines of at most `width` characters, breaking at word boundaries.
|
|
795
|
+
*/
|
|
796
|
+
function wrapText(text: string, width: number): string[] {
|
|
797
|
+
if (width <= 0 || text.length <= width) return [text];
|
|
798
|
+
const lines: string[] = [];
|
|
799
|
+
let pos = 0;
|
|
800
|
+
while (pos < text.length) {
|
|
801
|
+
if (pos + width >= text.length) {
|
|
802
|
+
lines.push(text.slice(pos));
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
let breakAt = text.lastIndexOf(' ', pos + width);
|
|
806
|
+
if (breakAt <= pos) {
|
|
807
|
+
breakAt = pos + width;
|
|
808
|
+
lines.push(text.slice(pos, breakAt));
|
|
809
|
+
pos = breakAt;
|
|
810
|
+
} else {
|
|
811
|
+
lines.push(text.slice(pos, breakAt));
|
|
812
|
+
pos = breakAt + 1;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return lines.length > 0 ? lines : [text];
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Process a single line: add overlays (emphasis, link styling) and conceals
|
|
820
|
+
* (hide markdown syntax markers). Cursor-aware: when cursor is inside a span,
|
|
821
|
+
* markers are revealed instead of concealed.
|
|
822
|
+
*/
|
|
823
|
+
function processLineConceals(
|
|
824
|
+
bufferId: number,
|
|
825
|
+
lineContent: string,
|
|
826
|
+
byteStart: number,
|
|
827
|
+
byteEnd: number,
|
|
828
|
+
cursors: number[],
|
|
829
|
+
lineNumber?: number,
|
|
830
|
+
): void {
|
|
831
|
+
// Clear existing conceals and overlays for this line first.
|
|
832
|
+
// This ensures clear+add commands are sent together from the plugin thread
|
|
833
|
+
// and processed atomically in the same process_commands() batch, avoiding
|
|
834
|
+
// the one-frame glitch where conceals are cleared but not yet rebuilt.
|
|
835
|
+
editor.debug(`[mc] processLine clear+rebuild bytes=${byteStart}..${byteEnd} content="${lineContent.slice(0,40)}"`);
|
|
836
|
+
editor.clearConcealsInRange(bufferId, byteStart, byteEnd);
|
|
837
|
+
editor.clearOverlaysInRange(bufferId, byteStart, byteEnd);
|
|
838
|
+
|
|
839
|
+
const cursorOnLine = cursors.some(c => c >= byteStart && c <= byteEnd);
|
|
840
|
+
// Strict version: excludes the boundary at byteEnd so that the cursor
|
|
841
|
+
// sitting at the start of the *next* line doesn't count as being on
|
|
842
|
+
// *this* line. Used for table row auto-expose to avoid exposing the
|
|
843
|
+
// previous row's emphasis markers.
|
|
844
|
+
const cursorStrictlyOnLine = cursors.some(c => c >= byteStart && c < byteEnd);
|
|
845
|
+
|
|
846
|
+
// Skip lines inside code fences (we'd need multi-line context for this;
|
|
847
|
+
// for now, detect fence lines and code content lines)
|
|
848
|
+
const trimmed = lineContent.trim();
|
|
849
|
+
if (trimmed.startsWith('```')) return; // fence line itself
|
|
850
|
+
|
|
851
|
+
// --- Table row handling ---
|
|
852
|
+
// Always apply table conceals even when cursor is on the line.
|
|
853
|
+
// Tables are structural: pipes → box-drawing, cells padded for alignment.
|
|
854
|
+
// Toggling conceals on/off per cursor line causes visual width shifts that
|
|
855
|
+
// break cursor navigation (stuck cursor, ghost cursors) and lose alignment.
|
|
856
|
+
const truncatedByteRanges: Array<{start: number; end: number}> = [];
|
|
857
|
+
let isTableRow = false;
|
|
858
|
+
if (trimmed.startsWith('|') || trimmed.endsWith('|')) {
|
|
859
|
+
isTableRow = true;
|
|
860
|
+
const isSeparator = /^\|[-:\s|]+\|$/.test(trimmed);
|
|
861
|
+
|
|
862
|
+
// Look up stored column widths for alignment padding
|
|
863
|
+
const bufWidths = lineNumber !== undefined ? getTableWidths(bufferId) : undefined;
|
|
864
|
+
const widthInfo = bufWidths && lineNumber !== undefined ? bufWidths.get(lineNumber) : undefined;
|
|
865
|
+
const colWidths = widthInfo ? widthInfo.allocated : undefined;
|
|
866
|
+
|
|
867
|
+
// Split the line into cells to compute per-cell padding
|
|
868
|
+
let inner = trimmed;
|
|
869
|
+
if (inner.startsWith('|')) inner = inner.slice(1);
|
|
870
|
+
if (inner.endsWith('|')) inner = inner.slice(0, -1);
|
|
871
|
+
const cells = inner.split('|');
|
|
872
|
+
|
|
873
|
+
// Check if any data cell needs multi-line wrapping
|
|
874
|
+
let handledByWrapping = false;
|
|
875
|
+
if (colWidths && !isSeparator && !cursorStrictlyOnLine) {
|
|
876
|
+
const numCols = Math.min(cells.length, colWidths.length);
|
|
877
|
+
const cellWrapped: string[][] = [];
|
|
878
|
+
let maxVisualLines = 1;
|
|
879
|
+
for (let ci = 0; ci < numCols; ci++) {
|
|
880
|
+
// When cursor is on the row, use raw text (emphasis markers revealed).
|
|
881
|
+
const cellText = cursorStrictlyOnLine ? cells[ci].trim() : concealedText(cells[ci]).trim();
|
|
882
|
+
const wrapW = Math.max(1, colWidths[ci] - 2); // 1 leading + 1 trailing space margin
|
|
883
|
+
const wrapped = wrapText(cellText, wrapW);
|
|
884
|
+
cellWrapped.push(wrapped);
|
|
885
|
+
maxVisualLines = Math.max(maxVisualLines, wrapped.length);
|
|
886
|
+
}
|
|
887
|
+
// Cap to available source bytes (excluding trailing newline)
|
|
888
|
+
let effLen = lineContent.length;
|
|
889
|
+
if (effLen > 0 && lineContent[effLen - 1] === '\n') effLen--;
|
|
890
|
+
if (effLen > 0 && lineContent[effLen - 1] === '\r') effLen--;
|
|
891
|
+
maxVisualLines = Math.min(maxVisualLines, effLen);
|
|
892
|
+
|
|
893
|
+
if (maxVisualLines > 1) {
|
|
894
|
+
// Build formatted visual line for each wrapped row
|
|
895
|
+
const visualLines: string[] = [];
|
|
896
|
+
for (let vl = 0; vl < maxVisualLines; vl++) {
|
|
897
|
+
let vline = '│';
|
|
898
|
+
for (let ci = 0; ci < numCols; ci++) {
|
|
899
|
+
const wrapW = Math.max(1, colWidths[ci] - 2);
|
|
900
|
+
const wrapped = cellWrapped[ci] || [];
|
|
901
|
+
const text = vl < wrapped.length ? wrapped[vl] : '';
|
|
902
|
+
vline += ' ' + text + ' '.repeat(Math.max(0, wrapW - text.length)) + ' │';
|
|
903
|
+
}
|
|
904
|
+
visualLines.push(vline);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Divide source bytes into segments, one per visual line.
|
|
908
|
+
// Soft breaks at segment boundaries (added by processLineSoftBreaks)
|
|
909
|
+
// create the visual line breaks; conceals replace each segment.
|
|
910
|
+
//
|
|
911
|
+
// IMPORTANT: break positions MUST land on Space characters.
|
|
912
|
+
// Space tokens have individual source_offset values matching their
|
|
913
|
+
// byte positions, so soft breaks will reliably trigger. Non-space
|
|
914
|
+
// characters inside Text tokens share the token's START offset,
|
|
915
|
+
// so breaks at mid-token positions silently fail.
|
|
916
|
+
// The consumed space (replaced by Newline) must NOT be covered by
|
|
917
|
+
// any segment's conceal range, so segment N+1 starts at spacePos+1.
|
|
918
|
+
// Exclude trailing newline from segment range so the Newline token
|
|
919
|
+
// at the end of the source line is NOT concealed (preserves the
|
|
920
|
+
// line break between adjacent source rows).
|
|
921
|
+
let lineCharLen = lineContent.length;
|
|
922
|
+
if (lineCharLen > 0 && lineContent[lineCharLen - 1] === '\n') lineCharLen--;
|
|
923
|
+
if (lineCharLen > 0 && lineContent[lineCharLen - 1] === '\r') lineCharLen--;
|
|
924
|
+
const spacePositions: number[] = [];
|
|
925
|
+
for (let i = 1; i < lineCharLen; i++) {
|
|
926
|
+
if (lineContent[i] === ' ') spacePositions.push(i);
|
|
927
|
+
}
|
|
928
|
+
const breakChars = spacePositions.slice(0, maxVisualLines - 1);
|
|
929
|
+
// Trim visual lines if we couldn't find enough break positions
|
|
930
|
+
const actualVisualLines = breakChars.length + 1;
|
|
931
|
+
// Segments: first starts at 0, subsequent start AFTER the consumed space
|
|
932
|
+
const segStarts = [0, ...breakChars.map(c => c + 1)];
|
|
933
|
+
const segEnds = [...breakChars, lineCharLen];
|
|
934
|
+
for (let vl = 0; vl < actualVisualLines; vl++) {
|
|
935
|
+
const sByteS = charToByte(lineContent, segStarts[vl], byteStart);
|
|
936
|
+
const sByteE = charToByte(lineContent, segEnds[vl], byteStart);
|
|
937
|
+
editor.addConceal(bufferId, "md-syntax", sByteS, sByteE, visualLines[vl] || '');
|
|
938
|
+
}
|
|
939
|
+
handledByWrapping = true;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (!handledByWrapping) {
|
|
944
|
+
// Find pipe positions for byte-range computation of truncated cells
|
|
945
|
+
const pipePositions: number[] = [];
|
|
946
|
+
for (let i = 0; i < lineContent.length; i++) {
|
|
947
|
+
if (lineContent[i] === '|') pipePositions.push(i);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Track which pipe index we're on (0 = leading pipe)
|
|
951
|
+
let pipeIdx = 0;
|
|
952
|
+
for (let i = 0; i < lineContent.length; i++) {
|
|
953
|
+
if (lineContent[i] === '|') {
|
|
954
|
+
const pipeByte = charToByte(lineContent, i, byteStart);
|
|
955
|
+
const pipeByteEnd = charToByte(lineContent, i + 1, byteStart);
|
|
956
|
+
|
|
957
|
+
// Compute padding or truncation for the cell that just ended.
|
|
958
|
+
// When the cursor is on this row, skip truncation/padding entirely
|
|
959
|
+
// so that only pipe→│ conceals exist. This ensures cursor positioning
|
|
960
|
+
// works correctly (segment conceals break cursor mapping).
|
|
961
|
+
let padding = "";
|
|
962
|
+
const cellIdx = pipeIdx - 1;
|
|
963
|
+
if (!cursorStrictlyOnLine && colWidths && pipeIdx > 0 && cellIdx < cells.length && cellIdx < colWidths.length) {
|
|
964
|
+
const cellText = concealedText(cells[cellIdx]);
|
|
965
|
+
const cellWidth = cellText.length;
|
|
966
|
+
const allocatedWidth = colWidths[cellIdx];
|
|
967
|
+
|
|
968
|
+
if (cellWidth > allocatedWidth) {
|
|
969
|
+
// Truncate: conceal entire cell content and replace with truncated text
|
|
970
|
+
const prevPipeCharPos = pipePositions[pipeIdx - 1];
|
|
971
|
+
const cellByteStart = charToByte(lineContent, prevPipeCharPos + 1, byteStart);
|
|
972
|
+
const cellByteEnd = pipeByte;
|
|
973
|
+
const truncated = cellText.slice(0, allocatedWidth - 1) + '-';
|
|
974
|
+
editor.addConceal(bufferId, "md-syntax", cellByteStart, cellByteEnd, truncated);
|
|
975
|
+
truncatedByteRanges.push({start: cellByteStart, end: cellByteEnd});
|
|
976
|
+
} else {
|
|
977
|
+
const padCount = allocatedWidth - cellWidth;
|
|
978
|
+
if (padCount > 0) {
|
|
979
|
+
padding = isSeparator ? "─".repeat(padCount) : " ".repeat(padCount);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (isSeparator) {
|
|
985
|
+
const pipeIndex = lineContent.substring(0, i + 1).split('|').length - 1;
|
|
986
|
+
const totalPipes = lineContent.split('|').length - 1;
|
|
987
|
+
let replacement = '┼';
|
|
988
|
+
if (pipeIndex === 1) replacement = '├';
|
|
989
|
+
else if (pipeIndex === totalPipes) replacement = '┤';
|
|
990
|
+
editor.addConceal(bufferId, "md-syntax", pipeByte, pipeByteEnd, padding + replacement);
|
|
991
|
+
} else {
|
|
992
|
+
editor.addConceal(bufferId, "md-syntax", pipeByte, pipeByteEnd, padding + "│");
|
|
993
|
+
}
|
|
994
|
+
pipeIdx++;
|
|
995
|
+
} else if (isSeparator && lineContent[i] === '-') {
|
|
996
|
+
const db = charToByte(lineContent, i, byteStart);
|
|
997
|
+
editor.addConceal(bufferId, "md-syntax", db, charToByte(lineContent, i + 1, byteStart), "─");
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
// For wrapped rows, entire line is concealed — skip emphasis processing.
|
|
1002
|
+
// For non-wrapped rows, fall through to emphasis / link / entity processing.
|
|
1003
|
+
if (handledByWrapping) return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// --- Image links:  → "Image: alt — url" ---
|
|
1007
|
+
const imageRe = /^!\[([^\]]*)\]\(([^)]+)\)$/;
|
|
1008
|
+
const imageMatch = trimmed.match(imageRe);
|
|
1009
|
+
if (imageMatch && !cursorOnLine) {
|
|
1010
|
+
const alt = imageMatch[1];
|
|
1011
|
+
const url = imageMatch[2];
|
|
1012
|
+
editor.addConceal(bufferId, "md-syntax", byteStart, byteEnd, `Image: ${alt} — ${url}`);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// --- Inline spans: code, emphasis, links, entities ---
|
|
1017
|
+
const spans = findInlineSpans(lineContent);
|
|
1018
|
+
for (const span of spans) {
|
|
1019
|
+
const byteCS = charToByte(lineContent, span.contentStart, byteStart);
|
|
1020
|
+
const byteCE = charToByte(lineContent, span.contentEnd, byteStart);
|
|
1021
|
+
const byteMS = charToByte(lineContent, span.matchStart, byteStart);
|
|
1022
|
+
const byteME = charToByte(lineContent, span.matchEnd, byteStart);
|
|
1023
|
+
|
|
1024
|
+
// Skip overlays and conceals for spans inside truncated table cells —
|
|
1025
|
+
// the cell content has already been fully replaced by truncated text.
|
|
1026
|
+
const inTruncated = truncatedByteRanges.some(r => byteMS >= r.start && byteME <= r.end);
|
|
1027
|
+
if (inTruncated) continue;
|
|
1028
|
+
|
|
1029
|
+
// Overlays (styling)
|
|
1030
|
+
switch (span.type) {
|
|
1031
|
+
case 'code':
|
|
1032
|
+
editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { fg: "syntax.constant" });
|
|
1033
|
+
break;
|
|
1034
|
+
case 'bold':
|
|
1035
|
+
editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { bold: true });
|
|
1036
|
+
break;
|
|
1037
|
+
case 'italic':
|
|
1038
|
+
editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { italic: true });
|
|
1039
|
+
break;
|
|
1040
|
+
case 'bold-italic':
|
|
1041
|
+
editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { bold: true, italic: true });
|
|
1042
|
+
break;
|
|
1043
|
+
case 'strikethrough':
|
|
1044
|
+
editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { strikethrough: true });
|
|
1045
|
+
break;
|
|
1046
|
+
case 'link':
|
|
1047
|
+
editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, {
|
|
1048
|
+
fg: "syntax.link",
|
|
1049
|
+
underline: true,
|
|
1050
|
+
url: span.linkUrl,
|
|
1051
|
+
});
|
|
1052
|
+
break;
|
|
1053
|
+
// entities: no overlay
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Conceals (cursor-aware).
|
|
1057
|
+
// For table rows: skip ALL emphasis conceals when cursor is on the line,
|
|
1058
|
+
// not just the span the cursor is in. This "auto-expose entire row"
|
|
1059
|
+
// approach keeps the row layout consistent with the raw-text-based
|
|
1060
|
+
// column widths, preventing overflow/wrapping.
|
|
1061
|
+
const cursorInSpan = cursors.some(c => c >= byteMS && c <= byteME);
|
|
1062
|
+
const skipConceal = (isTableRow && cursorStrictlyOnLine) || cursorInSpan;
|
|
1063
|
+
if (!skipConceal) {
|
|
1064
|
+
for (const range of span.concealRanges) {
|
|
1065
|
+
const rStart = charToByte(lineContent, range.start, byteStart);
|
|
1066
|
+
const rEnd = charToByte(lineContent, range.end, byteStart);
|
|
1067
|
+
editor.addConceal(bufferId, "md-syntax", rStart, rEnd, range.replacement);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Last cursor line is tracked per-buffer-per-split via setViewState/getViewState
|
|
1074
|
+
|
|
1075
|
+
// Track viewport width per buffer for resize detection
|
|
1076
|
+
let lastViewportWidth = 0;
|
|
1077
|
+
|
|
1078
|
+
// =============================================================================
|
|
1079
|
+
// Hook handlers
|
|
1080
|
+
// =============================================================================
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Compute soft break points for a single line, using the same block parsing
|
|
1084
|
+
* and word-wrap logic as the old transformMarkdownTokens, but emitting
|
|
1085
|
+
* marker-based soft breaks instead of view_transform tokens.
|
|
1086
|
+
*/
|
|
1087
|
+
function processLineSoftBreaks(
|
|
1088
|
+
bufferId: number,
|
|
1089
|
+
lineContent: string,
|
|
1090
|
+
byteStart: number,
|
|
1091
|
+
byteEnd: number,
|
|
1092
|
+
cursors: number[],
|
|
1093
|
+
lineNumber?: number,
|
|
1094
|
+
): void {
|
|
1095
|
+
// Clear existing soft breaks for this line range
|
|
1096
|
+
editor.clearSoftBreaksInRange(bufferId, byteStart, byteEnd);
|
|
1097
|
+
|
|
1098
|
+
const viewport = editor.getViewport();
|
|
1099
|
+
if (!viewport) return;
|
|
1100
|
+
const width = config.composeWidth ?? viewport.width;
|
|
1101
|
+
|
|
1102
|
+
// Parse this single line to get block structure
|
|
1103
|
+
const blocks = parseMarkdownBlocks(lineContent);
|
|
1104
|
+
if (blocks.length === 0) return;
|
|
1105
|
+
|
|
1106
|
+
const block = blocks[0]; // Single line = single block
|
|
1107
|
+
|
|
1108
|
+
// Determine if this block type should be soft-wrapped
|
|
1109
|
+
const noWrap = block.type === 'table-row' || block.type === 'code-fence' ||
|
|
1110
|
+
block.type === 'code-content' || block.type === 'hr' ||
|
|
1111
|
+
block.type === 'heading' || block.type === 'image' ||
|
|
1112
|
+
block.type === 'empty';
|
|
1113
|
+
|
|
1114
|
+
// Image blocks: add a trailing blank line for visual separation when concealed
|
|
1115
|
+
if (block.type === 'image') {
|
|
1116
|
+
const cursorOnLine = cursors.some(c => c >= byteStart && c <= byteEnd);
|
|
1117
|
+
if (!cursorOnLine) {
|
|
1118
|
+
editor.addSoftBreak(bufferId, "md-wrap", byteEnd - 1, 0);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Table row wrapping: add soft breaks for multi-line cells
|
|
1123
|
+
if (block.type === 'table-row' && lineNumber !== undefined) {
|
|
1124
|
+
const trimmedLine = lineContent.trim();
|
|
1125
|
+
const isSep = /^\|[-:\s|]+\|$/.test(trimmedLine);
|
|
1126
|
+
if (!isSep) {
|
|
1127
|
+
const bufWidths = getTableWidths(bufferId);
|
|
1128
|
+
const widthInfo = bufWidths ? bufWidths.get(lineNumber) : undefined;
|
|
1129
|
+
const colWidths = widthInfo ? widthInfo.allocated : undefined;
|
|
1130
|
+
if (colWidths) {
|
|
1131
|
+
let innerLine = trimmedLine;
|
|
1132
|
+
if (innerLine.startsWith('|')) innerLine = innerLine.slice(1);
|
|
1133
|
+
if (innerLine.endsWith('|')) innerLine = innerLine.slice(0, -1);
|
|
1134
|
+
const tableCells = innerLine.split('|');
|
|
1135
|
+
let maxVisualLines = 1;
|
|
1136
|
+
const numCols = Math.min(tableCells.length, colWidths.length);
|
|
1137
|
+
const cursorOnTableLine = cursors.some(c => c >= byteStart && c < byteEnd);
|
|
1138
|
+
for (let ci = 0; ci < numCols; ci++) {
|
|
1139
|
+
const cellText = cursorOnTableLine ? tableCells[ci].trim() : concealedText(tableCells[ci]).trim();
|
|
1140
|
+
const wrapW = Math.max(1, colWidths[ci] - 2);
|
|
1141
|
+
const wrapped = wrapText(cellText, wrapW);
|
|
1142
|
+
maxVisualLines = Math.max(maxVisualLines, wrapped.length);
|
|
1143
|
+
}
|
|
1144
|
+
// Exclude trailing newline (same as processLineConceals)
|
|
1145
|
+
let effLineLen = lineContent.length;
|
|
1146
|
+
if (effLineLen > 0 && lineContent[effLineLen - 1] === '\n') effLineLen--;
|
|
1147
|
+
if (effLineLen > 0 && lineContent[effLineLen - 1] === '\r') effLineLen--;
|
|
1148
|
+
maxVisualLines = Math.min(maxVisualLines, effLineLen);
|
|
1149
|
+
|
|
1150
|
+
if (maxVisualLines > 1) {
|
|
1151
|
+
// Must match the break positions from processLineConceals:
|
|
1152
|
+
// pick Space chars (they have individual source_offsets that match).
|
|
1153
|
+
const spacePositions: number[] = [];
|
|
1154
|
+
for (let i = 1; i < effLineLen; i++) {
|
|
1155
|
+
if (lineContent[i] === ' ') spacePositions.push(i);
|
|
1156
|
+
}
|
|
1157
|
+
const breakChars = spacePositions.slice(0, maxVisualLines - 1);
|
|
1158
|
+
for (const charPos of breakChars) {
|
|
1159
|
+
const breakBytePos = byteStart + editor.utf8ByteLength(lineContent.slice(0, charPos));
|
|
1160
|
+
editor.addSoftBreak(bufferId, "md-wrap", breakBytePos, 0);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (noWrap) return;
|
|
1168
|
+
|
|
1169
|
+
const hangingIndent = block.hangingIndent;
|
|
1170
|
+
|
|
1171
|
+
// Compute per-character visual width so concealed markup (emphasis
|
|
1172
|
+
// markers, link syntax, entities) doesn't count towards line width.
|
|
1173
|
+
const spans = findInlineSpans(lineContent);
|
|
1174
|
+
const charW = new Array<number>(lineContent.length).fill(1);
|
|
1175
|
+
for (const span of spans) {
|
|
1176
|
+
for (const range of span.concealRanges) {
|
|
1177
|
+
for (let c = range.start; c < range.end && c < lineContent.length; c++) {
|
|
1178
|
+
charW[c] = 0;
|
|
1179
|
+
}
|
|
1180
|
+
// Entity replacements contribute their replacement's length
|
|
1181
|
+
if (range.replacement !== null && range.start < lineContent.length) {
|
|
1182
|
+
charW[range.start] = range.replacement.length;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Walk through the line content and find word-wrap break points
|
|
1188
|
+
// We need to find Space positions where wrapping should occur
|
|
1189
|
+
let column = 0;
|
|
1190
|
+
let i = 0;
|
|
1191
|
+
|
|
1192
|
+
while (i < lineContent.length) {
|
|
1193
|
+
const ch = lineContent[i];
|
|
1194
|
+
|
|
1195
|
+
if (ch === ' ' && column > 0 && charW[i] > 0) {
|
|
1196
|
+
// Look ahead to find the next word's visual length
|
|
1197
|
+
let nextWordLen = 0;
|
|
1198
|
+
for (let j = i + 1; j < lineContent.length; j++) {
|
|
1199
|
+
if ((lineContent[j] === ' ' || lineContent[j] === '\n') && charW[j] > 0) break;
|
|
1200
|
+
nextWordLen += charW[j];
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Check if space + next word would exceed width
|
|
1204
|
+
if (column + 1 + nextWordLen > width && nextWordLen > 0) {
|
|
1205
|
+
// Add a soft break at this space's buffer position
|
|
1206
|
+
const breakBytePos = byteStart + editor.utf8ByteLength(lineContent.slice(0, i));
|
|
1207
|
+
editor.addSoftBreak(bufferId, "md-wrap", breakBytePos, hangingIndent);
|
|
1208
|
+
column = hangingIndent;
|
|
1209
|
+
i++;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
column += charW[i];
|
|
1215
|
+
i++;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Pre-compute column widths for table groups in a batch of lines.
|
|
1221
|
+
* Groups consecutive table rows and computes max visible width per column.
|
|
1222
|
+
*
|
|
1223
|
+
* Uses an accumulate-and-grow strategy: widths are merged with previously
|
|
1224
|
+
* cached values (taking the max per column) so that as the user scrolls
|
|
1225
|
+
* through a large table, column widths converge to the true maximum and
|
|
1226
|
+
* never shrink.
|
|
1227
|
+
*/
|
|
1228
|
+
function processTableAlignment(
|
|
1229
|
+
bufferId: number,
|
|
1230
|
+
lines: Array<{ line_number: number; byte_start: number; byte_end: number; content: string }>,
|
|
1231
|
+
): boolean {
|
|
1232
|
+
// Get existing cache (accumulate-and-grow — don't discard previous widths)
|
|
1233
|
+
const widthMap = getTableWidths(bufferId) ?? new Map<number, TableWidthInfo>();
|
|
1234
|
+
let needsRefresh = false;
|
|
1235
|
+
|
|
1236
|
+
// Group consecutive table rows
|
|
1237
|
+
const groups: Array<typeof lines> = [];
|
|
1238
|
+
let currentGroup: typeof lines = [];
|
|
1239
|
+
let lastLineNum = -2;
|
|
1240
|
+
|
|
1241
|
+
for (const line of lines) {
|
|
1242
|
+
const trimmed = line.content.trim();
|
|
1243
|
+
const isTableRow = trimmed.startsWith('|') || trimmed.endsWith('|');
|
|
1244
|
+
if (isTableRow && line.line_number === lastLineNum + 1) {
|
|
1245
|
+
currentGroup.push(line);
|
|
1246
|
+
} else if (isTableRow) {
|
|
1247
|
+
if (currentGroup.length > 0) groups.push(currentGroup);
|
|
1248
|
+
currentGroup = [line];
|
|
1249
|
+
} else {
|
|
1250
|
+
if (currentGroup.length > 0) groups.push(currentGroup);
|
|
1251
|
+
currentGroup = [];
|
|
1252
|
+
}
|
|
1253
|
+
lastLineNum = line.line_number;
|
|
1254
|
+
}
|
|
1255
|
+
if (currentGroup.length > 0) groups.push(currentGroup);
|
|
1256
|
+
|
|
1257
|
+
// For each group, compute max column widths and merge with cache
|
|
1258
|
+
for (const group of groups) {
|
|
1259
|
+
const allCells: string[][] = [];
|
|
1260
|
+
|
|
1261
|
+
for (const line of group) {
|
|
1262
|
+
const trimmed = line.content.trim();
|
|
1263
|
+
// Strip outer pipes and split on inner pipes
|
|
1264
|
+
let inner = trimmed;
|
|
1265
|
+
if (inner.startsWith('|')) inner = inner.slice(1);
|
|
1266
|
+
if (inner.endsWith('|')) inner = inner.slice(0, -1);
|
|
1267
|
+
const cells = inner.split('|');
|
|
1268
|
+
allCells.push(cells);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Find max column count
|
|
1272
|
+
const maxCols = allCells.reduce((max, row) => Math.max(max, row.length), 0);
|
|
1273
|
+
|
|
1274
|
+
// Compute max visible width per column from the currently visible rows
|
|
1275
|
+
const newWidths: number[] = [];
|
|
1276
|
+
for (let col = 0; col < maxCols; col++) {
|
|
1277
|
+
let maxW = 0;
|
|
1278
|
+
for (const row of allCells) {
|
|
1279
|
+
if (col < row.length) {
|
|
1280
|
+
// For separator rows, use 0 width (they adapt to data rows).
|
|
1281
|
+
// Use RAW text width (not concealedText) so that columns are always
|
|
1282
|
+
// sized to accommodate revealed emphasis markers when cursor enters
|
|
1283
|
+
// a row. Concealed rows simply get extra padding.
|
|
1284
|
+
const isSep = /^[-:\s]+$/.test(row[col]);
|
|
1285
|
+
if (!isSep) {
|
|
1286
|
+
maxW = Math.max(maxW, row[col].length);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
newWidths.push(maxW);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Merge with any previously cached maxW arrays for lines in this group
|
|
1294
|
+
// (they may have been computed from a different visible slice of the
|
|
1295
|
+
// same table). Take the max per column — widths only grow.
|
|
1296
|
+
let merged = newWidths;
|
|
1297
|
+
const mergeWith = (cached: number[]) => {
|
|
1298
|
+
const cols = Math.max(merged.length, cached.length);
|
|
1299
|
+
const wider: number[] = [];
|
|
1300
|
+
for (let c = 0; c < cols; c++) {
|
|
1301
|
+
wider.push(Math.max(merged[c] ?? 0, cached[c] ?? 0));
|
|
1302
|
+
}
|
|
1303
|
+
merged = wider;
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
for (const line of group) {
|
|
1307
|
+
const cached = widthMap.get(line.line_number);
|
|
1308
|
+
if (cached) mergeWith(cached.maxW);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Also merge with adjacent cached lines above/below the group.
|
|
1312
|
+
// When mouse-scrolling, lines_changed only delivers NEW lines (not
|
|
1313
|
+
// previously seen), so the group may not overlap with earlier cached
|
|
1314
|
+
// rows of the same table. Scanning adjacently bridges the gap.
|
|
1315
|
+
const firstLine = group[0].line_number;
|
|
1316
|
+
const lastLine = group[group.length - 1].line_number;
|
|
1317
|
+
for (let ln = firstLine - 1; widthMap.has(ln); ln--) {
|
|
1318
|
+
mergeWith(widthMap.get(ln)!.maxW);
|
|
1319
|
+
}
|
|
1320
|
+
for (let ln = lastLine + 1; widthMap.has(ln); ln++) {
|
|
1321
|
+
mergeWith(widthMap.get(ln)!.maxW);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Compute allocated widths constrained to viewport
|
|
1325
|
+
const viewport = editor.getViewport();
|
|
1326
|
+
const composeW = config.composeWidth ?? (viewport ? viewport.width : 80);
|
|
1327
|
+
const numCols = merged.length;
|
|
1328
|
+
const available = composeW - (numCols + 1); // subtract pipe/box-drawing characters
|
|
1329
|
+
const allocated = distributeColumnWidths(merged, available);
|
|
1330
|
+
|
|
1331
|
+
// Check if adjacent cached lines had narrower allocated widths — if so,
|
|
1332
|
+
// they need their conceals recomputed (they were already rendered with
|
|
1333
|
+
// old widths and won't be re-delivered by lines_changed).
|
|
1334
|
+
const allocGrew = (old: TableWidthInfo) =>
|
|
1335
|
+
allocated.some((w, i) => w > (old.allocated[i] ?? 0));
|
|
1336
|
+
for (let ln = firstLine - 1; widthMap.has(ln); ln--) {
|
|
1337
|
+
if (allocGrew(widthMap.get(ln)!)) { needsRefresh = true; break; }
|
|
1338
|
+
}
|
|
1339
|
+
for (let ln = lastLine + 1; widthMap.has(ln); ln++) {
|
|
1340
|
+
if (allocGrew(widthMap.get(ln)!)) { needsRefresh = true; break; }
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Store merged widths for all lines in the group AND propagate
|
|
1344
|
+
// back to adjacent cached lines so they pick up wider columns
|
|
1345
|
+
// without needing to be re-delivered by lines_changed.
|
|
1346
|
+
const info: TableWidthInfo = { maxW: merged, allocated };
|
|
1347
|
+
for (const line of group) {
|
|
1348
|
+
widthMap.set(line.line_number, info);
|
|
1349
|
+
}
|
|
1350
|
+
for (let ln = firstLine - 1; widthMap.has(ln); ln--) {
|
|
1351
|
+
widthMap.set(ln, info);
|
|
1352
|
+
}
|
|
1353
|
+
for (let ln = lastLine + 1; widthMap.has(ln); ln++) {
|
|
1354
|
+
widthMap.set(ln, info);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
setTableWidths(bufferId, widthMap);
|
|
1359
|
+
return needsRefresh;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// lines_changed: called for newly visible or invalidated lines
|
|
1363
|
+
globalThis.onMarkdownLinesChanged = function(data: {
|
|
517
1364
|
buffer_id: number;
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
1365
|
+
lines: Array<{
|
|
1366
|
+
line_number: number;
|
|
1367
|
+
byte_start: number;
|
|
1368
|
+
byte_end: number;
|
|
1369
|
+
content: string;
|
|
1370
|
+
}>;
|
|
522
1371
|
}): void {
|
|
523
|
-
|
|
524
|
-
|
|
1372
|
+
if (!isComposingInAnySplit(data.buffer_id)) return;
|
|
1373
|
+
const lineNums = data.lines.map(l => `${l.line_number}(${l.byte_start}..${l.byte_end})`).join(', ');
|
|
1374
|
+
editor.debug(`[mc] lines_changed: ${data.lines.length} lines: [${lineNums}]`);
|
|
1375
|
+
// Only use cursor positions for reveal/conceal decisions when the active
|
|
1376
|
+
// split is in compose mode. When a source-mode split is active, the cursor
|
|
1377
|
+
// lives in that source view — it should NOT trigger "reveal" (skip-conceal)
|
|
1378
|
+
// in the compose-mode split, because conceals are buffer-level decorations
|
|
1379
|
+
// shared across splits.
|
|
1380
|
+
const cursors = isComposing(data.buffer_id) ? [editor.getCursorPosition()] : [];
|
|
1381
|
+
|
|
1382
|
+
// Pre-compute table column widths for alignment.
|
|
1383
|
+
// If widths grew from merging with adjacent cached rows (e.g. after a
|
|
1384
|
+
// mouse-scroll jump), force a full re-render so already-visible lines
|
|
1385
|
+
// pick up the wider columns. The second pass will be a no-op (widths
|
|
1386
|
+
// already converged) so this doesn't loop.
|
|
1387
|
+
const tableWidthsGrew = processTableAlignment(data.buffer_id, data.lines);
|
|
1388
|
+
|
|
1389
|
+
for (const line of data.lines) {
|
|
1390
|
+
processLineConceals(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number);
|
|
1391
|
+
processLineSoftBreaks(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number);
|
|
1392
|
+
}
|
|
525
1393
|
|
|
526
|
-
|
|
527
|
-
|
|
1394
|
+
if (tableWidthsGrew) {
|
|
1395
|
+
editor.refreshLines(data.buffer_id);
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
// after_insert: no-op for conceals/overlays.
|
|
1400
|
+
// The edit automatically invalidates seen_byte_ranges for affected lines,
|
|
1401
|
+
// causing lines_changed to fire on the next render. processLineConceals
|
|
1402
|
+
// handles clearing and rebuilding atomically.
|
|
1403
|
+
// Marker-based positions auto-adjust with buffer edits, so existing conceals
|
|
1404
|
+
// remain visually correct until lines_changed rebuilds them.
|
|
1405
|
+
globalThis.onMarkdownAfterInsert = function(data: {
|
|
1406
|
+
buffer_id: number;
|
|
1407
|
+
position: number;
|
|
1408
|
+
text: string;
|
|
1409
|
+
affected_start: number;
|
|
1410
|
+
affected_end: number;
|
|
1411
|
+
}): void {
|
|
1412
|
+
if (!isComposingInAnySplit(data.buffer_id)) return;
|
|
1413
|
+
editor.debug(`[mc] after_insert: pos=${data.position} text="${data.text.replace(/\n/g,'\\n')}" affected=${data.affected_start}..${data.affected_end}`);
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
// after_delete: no-op for conceals/overlays (same reasoning as after_insert).
|
|
1417
|
+
globalThis.onMarkdownAfterDelete = function(data: {
|
|
1418
|
+
buffer_id: number;
|
|
1419
|
+
start: number;
|
|
1420
|
+
end: number;
|
|
1421
|
+
deleted_text: string;
|
|
1422
|
+
affected_start: number;
|
|
1423
|
+
deleted_len: number;
|
|
1424
|
+
}): void {
|
|
1425
|
+
if (!isComposingInAnySplit(data.buffer_id)) return;
|
|
1426
|
+
editor.debug(`[mc] after_delete: start=${data.start} end=${data.end} deleted="${data.deleted_text.replace(/\n/g,'\\n')}" affected_start=${data.affected_start} deleted_len=${data.deleted_len}`);
|
|
1427
|
+
};
|
|
528
1428
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
};
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
data.viewport_end,
|
|
549
|
-
transformedTokens,
|
|
550
|
-
layoutHints
|
|
551
|
-
);
|
|
1429
|
+
// cursor_moved: update cursor-aware reveal/conceal for old and new cursor lines
|
|
1430
|
+
globalThis.onMarkdownCursorMoved = function(data: {
|
|
1431
|
+
buffer_id: number;
|
|
1432
|
+
cursor_id: number;
|
|
1433
|
+
old_position: number;
|
|
1434
|
+
new_position: number;
|
|
1435
|
+
line: number;
|
|
1436
|
+
}): void {
|
|
1437
|
+
if (!isComposingInAnySplit(data.buffer_id)) return;
|
|
1438
|
+
|
|
1439
|
+
const prevLine = editor.getViewState(data.buffer_id, "last-cursor-line") as number | undefined;
|
|
1440
|
+
editor.setViewState(data.buffer_id, "last-cursor-line", data.line);
|
|
1441
|
+
|
|
1442
|
+
editor.debug(`[mc] cursor_moved: old_pos=${data.old_position} new_pos=${data.new_position} line=${data.line} prevLine=${prevLine}`);
|
|
1443
|
+
|
|
1444
|
+
// Always refresh: even intra-line movements need conceal updates because
|
|
1445
|
+
// auto-expose is span-level (cursor entering/leaving an emphasis or link
|
|
1446
|
+
// span within the same line must toggle its syntax markers).
|
|
1447
|
+
editor.refreshLines(data.buffer_id);
|
|
552
1448
|
};
|
|
553
1449
|
|
|
1450
|
+
// view_transform_request is no longer needed — soft wrapping is handled by
|
|
1451
|
+
// marker-based soft breaks (computed in lines_changed), and layout hints
|
|
1452
|
+
// are set directly via setLayoutHints. This eliminates the one-frame flicker
|
|
1453
|
+
// caused by the async view_transform round-trip.
|
|
1454
|
+
|
|
554
1455
|
// Handle buffer close events - clean up compose mode tracking
|
|
555
1456
|
globalThis.onMarkdownBufferClosed = function(data: { buffer_id: number }): void {
|
|
556
|
-
|
|
1457
|
+
// View state is cleaned up automatically when the buffer is removed from keyed_states
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
// viewport_changed: recalculate table column widths on terminal resize
|
|
1461
|
+
globalThis.onMarkdownViewportChanged = function(data: {
|
|
1462
|
+
split_id: number;
|
|
1463
|
+
buffer_id: number;
|
|
1464
|
+
top_byte: number;
|
|
1465
|
+
width: number;
|
|
1466
|
+
height: number;
|
|
1467
|
+
}): void {
|
|
1468
|
+
if (!isComposingInAnySplit(data.buffer_id)) return;
|
|
1469
|
+
if (data.width === lastViewportWidth) return;
|
|
1470
|
+
lastViewportWidth = data.width;
|
|
1471
|
+
|
|
1472
|
+
// Recompute allocated table column widths for new viewport width
|
|
1473
|
+
const bufWidths = getTableWidths(data.buffer_id);
|
|
1474
|
+
if (bufWidths) {
|
|
1475
|
+
const composeW = config.composeWidth ?? data.width;
|
|
1476
|
+
const seen = new Set<string>(); // Track by JSON key to deduplicate shared TableWidthInfo
|
|
1477
|
+
for (const [lineNum, info] of bufWidths) {
|
|
1478
|
+
const key = info.maxW.join(",");
|
|
1479
|
+
if (seen.has(key)) continue;
|
|
1480
|
+
seen.add(key);
|
|
1481
|
+
const numCols = info.maxW.length;
|
|
1482
|
+
const available = composeW - (numCols + 1);
|
|
1483
|
+
info.allocated = distributeColumnWidths(info.maxW, available);
|
|
1484
|
+
}
|
|
1485
|
+
setTableWidths(data.buffer_id, bufWidths);
|
|
1486
|
+
}
|
|
1487
|
+
editor.refreshLines(data.buffer_id);
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
// Re-enable compose mode for buffers restored from a saved session.
|
|
1491
|
+
// The Rust side restores ViewMode::Compose and compose_width, but the plugin
|
|
1492
|
+
// needs to re-apply line numbers, line wrap, and layout hints when activated.
|
|
1493
|
+
globalThis.onMarkdownBufferActivated = function(data: { buffer_id: number }): void {
|
|
1494
|
+
const bufferId = data.buffer_id;
|
|
1495
|
+
|
|
1496
|
+
const info = editor.getBufferInfo(bufferId);
|
|
1497
|
+
if (!info || !isMarkdownFile(info.path)) return;
|
|
1498
|
+
|
|
1499
|
+
if (info.view_mode === "compose") {
|
|
1500
|
+
// Restore config.composeWidth from the persisted session value
|
|
1501
|
+
// before enabling compose mode, so enableMarkdownCompose uses
|
|
1502
|
+
// the correct width (same path as a fresh toggle).
|
|
1503
|
+
if (info.compose_width != null) {
|
|
1504
|
+
config.composeWidth = info.compose_width;
|
|
1505
|
+
}
|
|
1506
|
+
enableMarkdownCompose(bufferId);
|
|
1507
|
+
}
|
|
557
1508
|
};
|
|
558
1509
|
|
|
559
1510
|
// Register hooks
|
|
560
|
-
editor.on("
|
|
1511
|
+
editor.on("lines_changed", "onMarkdownLinesChanged");
|
|
1512
|
+
editor.on("after_insert", "onMarkdownAfterInsert");
|
|
1513
|
+
editor.on("after_delete", "onMarkdownAfterDelete");
|
|
1514
|
+
editor.on("cursor_moved", "onMarkdownCursorMoved");
|
|
1515
|
+
// view_transform_request hook no longer needed — wrapping is handled by soft breaks
|
|
561
1516
|
editor.on("buffer_closed", "onMarkdownBufferClosed");
|
|
1517
|
+
editor.on("viewport_changed", "onMarkdownViewportChanged");
|
|
562
1518
|
editor.on("prompt_confirmed", "onMarkdownComposeWidthConfirmed");
|
|
1519
|
+
editor.on("buffer_activated", "onMarkdownBufferActivated");
|
|
563
1520
|
|
|
564
1521
|
// Set compose width command - starts interactive prompt
|
|
565
1522
|
globalThis.markdownSetComposeWidth = function(): void {
|
|
566
|
-
|
|
1523
|
+
const currentValue = config.composeWidth === null ? "None" : String(config.composeWidth);
|
|
1524
|
+
editor.startPromptWithInitial(editor.t("prompt.compose_width"), "markdown-compose-width", currentValue);
|
|
1525
|
+
editor.setPromptInputSync(true);
|
|
567
1526
|
editor.setPromptSuggestions([
|
|
568
|
-
{ text: "
|
|
569
|
-
{ text: "
|
|
570
|
-
{ text: "80", description: editor.t("suggestion.standard") },
|
|
571
|
-
{ text: "100", description: editor.t("suggestion.wide") },
|
|
1527
|
+
{ text: "None", description: editor.t("suggestion.none") },
|
|
1528
|
+
{ text: "120", description: editor.t("suggestion.default") },
|
|
572
1529
|
]);
|
|
573
1530
|
};
|
|
574
1531
|
|
|
575
1532
|
// Handle compose width prompt confirmation
|
|
576
1533
|
globalThis.onMarkdownComposeWidthConfirmed = function(args: {
|
|
577
1534
|
prompt_type: string;
|
|
578
|
-
|
|
1535
|
+
input: string;
|
|
579
1536
|
}): void {
|
|
580
1537
|
if (args.prompt_type !== "markdown-compose-width") return;
|
|
581
1538
|
|
|
582
|
-
const
|
|
1539
|
+
const input = args.input.trim();
|
|
1540
|
+
if (input.toLowerCase() === "none") {
|
|
1541
|
+
config.composeWidth = null;
|
|
1542
|
+
editor.setStatus(editor.t("status.width_none"));
|
|
1543
|
+
|
|
1544
|
+
const bufferId = editor.getActiveBufferId();
|
|
1545
|
+
if (isComposing(bufferId)) {
|
|
1546
|
+
editor.setLayoutHints(bufferId, null, { composeWidth: null });
|
|
1547
|
+
editor.refreshLines(bufferId);
|
|
1548
|
+
}
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const width = parseInt(input, 10);
|
|
583
1553
|
if (!isNaN(width) && width > 20 && width < 300) {
|
|
584
1554
|
config.composeWidth = width;
|
|
585
1555
|
editor.setStatus(editor.t("status.width_set", { width: String(width) }));
|
|
586
1556
|
|
|
587
1557
|
// Re-process active buffer if in compose mode
|
|
588
1558
|
const bufferId = editor.getActiveBufferId();
|
|
589
|
-
if (
|
|
590
|
-
editor.
|
|
1559
|
+
if (isComposing(bufferId)) {
|
|
1560
|
+
editor.setLayoutHints(bufferId, null, { composeWidth: config.composeWidth });
|
|
1561
|
+
editor.refreshLines(bufferId); // Trigger soft break recomputation
|
|
591
1562
|
}
|
|
592
1563
|
} else {
|
|
593
1564
|
editor.setStatus(editor.t("status.invalid_width"));
|
|
@@ -599,14 +1570,14 @@ editor.registerCommand(
|
|
|
599
1570
|
"%cmd.toggle_compose",
|
|
600
1571
|
"%cmd.toggle_compose_desc",
|
|
601
1572
|
"markdownToggleCompose",
|
|
602
|
-
|
|
1573
|
+
null
|
|
603
1574
|
);
|
|
604
1575
|
|
|
605
1576
|
editor.registerCommand(
|
|
606
1577
|
"%cmd.set_compose_width",
|
|
607
1578
|
"%cmd.set_compose_width_desc",
|
|
608
1579
|
"markdownSetComposeWidth",
|
|
609
|
-
|
|
1580
|
+
null
|
|
610
1581
|
);
|
|
611
1582
|
|
|
612
1583
|
// Initialization
|