-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheditor.js
More file actions
1126 lines (964 loc) · 39.6 KB
/
editor.js
File metadata and controls
1126 lines (964 loc) · 39.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// editor.js — Clean unified version
import { Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx } from "@milkdown/core";
import { replaceAll } from "@milkdown/utils";
import { listener, listenerCtx } from "@milkdown/plugin-listener";
import { commonmark } from "@milkdown/preset-commonmark";
import { gfm } from "@milkdown/preset-gfm";
import { underlinePlugin } from "./milkdown-underline.js";
import { figurePlugin, rebuildFigureRegistry, resetFigureRegistry } from "./milkdown-figure.js";
// Re-export editorViewCtx so other modules can use it with the editor instance
export { editorViewCtx };
// Re-export figure registry functions for use by other modules
export { rebuildFigureRegistry, resetFigureRegistry };
import { Plugin, PluginKey, TextSelection } from "@milkdown/prose/state";
import { Decoration, DecorationSet } from "@milkdown/prose/view";
import { ySyncPlugin, yUndoPlugin, undo, redo } from "y-prosemirror";
import { keymap } from "@milkdown/prose/keymap";
import { invoke } from "@tauri-apps/api/core";
import { ydoc, yXmlFragment, loadInitialDoc, forceSave, enablePersistence, switchDocument, loadDocumentState, isApplyingUpdate } from "./yjs-setup.js";
import { stepToSemanticPatch } from "./patch-extractor.js";
import {
addSemanticPatches,
flushGroup
} from "./patch-grouper.js";
import { initProfile } from "./profile-service.js";
import { getActiveDocumentId, onDocumentChange } from "./document-manager.js";
import { calculateCharDiff } from "./diff-highlighter.js";
import { stripMarkdown } from "./utils.js";
// Create a unique key for the highlight plugin
const highlightKey = new PluginKey("hunk-highlight");
/**
* Plugin to render transient highlights for hunk hovering.
* Now supports "Ghost Preview" (widgets for inserts, inline for deletes).
*/
const hunkHighlightPlugin = new Plugin({
key: highlightKey,
state: {
init() {
return DecorationSet ? DecorationSet.empty : null;
},
apply(tr, set) {
try {
if (!DecorationSet || !Decoration) return set;
// map existing decorations
set = set.map(tr.mapping, tr.doc);
// Check for our meta action
const action = tr.getMeta(highlightKey);
if (action) {
if (action.type === 'set') {
// Standard Highlight (Yellow Background)
let to = action.to;
if (to > tr.doc.content.size) to = tr.doc.content.size;
let from = action.from;
if (from >= to) {
to = Math.min(from + 1, tr.doc.content.size);
if (from === to && from > 0) from = from - 1;
}
return DecorationSet.create(tr.doc, [
Decoration.inline(from, to, { class: 'hunk-hover-highlight' })
]);
} else if (action.type === 'preview') {
// Ghost Preview
const decorations = [];
// Helper to create insert widget
const createInsert = (text, pos) => {
const safePos = Math.min(pos, tr.doc.content.size);
// Use side: -1 for end-of-document to ensure widget renders
const side = safePos >= tr.doc.content.size ? -1 : 1;
return Decoration.widget(safePos, (view) => {
const span = document.createElement('span');
span.className = 'ghost-insert';
span.textContent = text;
return span;
}, { side });
};
// Helper to create delete inline
const createDelete = (from, to) => {
if (to > tr.doc.content.size) to = tr.doc.content.size;
return Decoration.inline(from, to, { class: 'ghost-delete' });
};
if (action.kind === 'insert') {
decorations.push(createInsert(action.text, action.from));
} else if (action.kind === 'delete') {
decorations.push(createDelete(action.from, action.to));
} else if (action.kind === 'replace') {
// Replace = Delete Range + Insert Widget at end of range
if (action.deleteFrom !== undefined && action.deleteTo !== undefined) {
decorations.push(createDelete(action.deleteFrom, action.deleteTo));
// Insert after the deletion
decorations.push(createInsert(action.text, action.deleteTo));
}
}
return DecorationSet.create(tr.doc, decorations);
} else if (action.type === 'diffPreview') {
// Full diff preview with multiple operations
const decorations = [];
const ops = action.operations || [];
// Helper to create insert widget
const createInsertWidget = (text, pos) => {
const safePos = Math.min(pos, tr.doc.content.size);
// Use side: -1 for end-of-document to ensure widget renders
// (side: 1 at doc.content.size has nothing to attach after)
const side = safePos >= tr.doc.content.size ? -1 : 1;
return Decoration.widget(safePos, () => {
const span = document.createElement('span');
span.className = 'ghost-insert';
span.textContent = text;
return span;
}, { side });
};
// Helper to create delete inline decoration
const createDeleteDecoration = (from, to) => {
const safeFrom = Math.max(0, Math.min(from, tr.doc.content.size));
const safeTo = Math.max(safeFrom, Math.min(to, tr.doc.content.size));
if (safeFrom >= safeTo) return null;
return Decoration.inline(safeFrom, safeTo, { class: 'ghost-delete' });
};
for (const op of ops) {
if (op.type === 'add' && op.text) {
decorations.push(createInsertWidget(op.text, op.pos));
} else if (op.type === 'delete' && op.from !== undefined && op.to !== undefined) {
const deco = createDeleteDecoration(op.from, op.to);
if (deco) decorations.push(deco);
}
}
return DecorationSet.create(tr.doc, decorations);
} else if (action.type === 'clear') {
return DecorationSet.empty;
}
}
return set;
} catch (err) {
console.error("Hunk Highlight Plugin Check Failed:", err);
return set;
}
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
});
/**
* Highlight a range in the editor (transient)
*/
export function highlightEditorRange(from, to) {
if (editor) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const tr = view.state.tr.setMeta(highlightKey, { type: 'set', from, to });
view.dispatch(tr);
});
}
}
/**
* Clear the current highlight/preview
*/
export function clearEditorHighlight() {
if (editor) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const tr = view.state.tr.setMeta(highlightKey, { type: 'clear' });
view.dispatch(tr);
});
}
}
/**
* Show a full diff preview in the editor using ghost decorations.
* @param {Array} operations - Array of {type: 'add'|'delete', text, pos, from, to}
* - For 'add': {type: 'add', text: string, pos: number}
* - For 'delete': {type: 'delete', from: number, to: number}
*/
export function showDiffPreview(operations) {
if (!editor) return;
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const tr = view.state.tr.setMeta(highlightKey, {
type: 'diffPreview',
operations
});
view.dispatch(tr);
});
}
/**
* Build a mapping from character offsets (in plain text) to ProseMirror positions.
* Returns a function that converts character offset to PM position.
* @returns {{charToPm: function(number): number, pmText: string}}
*/
export function getCharToPmMapping() {
if (!editor) return { charToPm: (n) => n, pmText: '' };
let result = { charToPm: (n) => n, pmText: '' };
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const doc = view.state.doc;
// Build arrays: charOffsets[i] = PM position for character i in extracted text
const charOffsets = [];
let textContent = '';
doc.descendants((node, pos) => {
if (node.isText) {
for (let i = 0; i < node.text.length; i++) {
charOffsets.push(pos + i);
textContent += node.text[i];
}
} else if (node.isBlock && charOffsets.length > 0) {
// Add newline for block boundaries
// Map the newline to the position RIGHT AFTER the last character,
// not to the start of the next block. This ensures insertions at
// block boundaries appear at the end of the previous block's content.
const lastCharPos = charOffsets[charOffsets.length - 1];
charOffsets.push(lastCharPos + 1);
textContent += '\n';
}
return true;
});
result = {
charToPm: (charOffset) => {
if (charOffset < 0) return 0;
if (charOffset >= charOffsets.length) {
return charOffsets.length > 0 ? charOffsets[charOffsets.length - 1] + 1 : doc.content.size;
}
return charOffsets[charOffset];
},
pmText: textContent,
docSize: doc.content.size
};
});
return result;
}
/**
* Build a robust mapping from Markdown Offsets to ProseMirror Positions.
* Uses the serializer to map Blocks to Markdown ranges.
*
* Returns: {
* charToPm: (mdOffset) => pmPos,
* blockMap: Array<{ mdStart, mdEnd, pmStart, pmEnd, text }>
* }
*/
import { computeBlockMapping } from './mapping-logic.js';
export function getMarkdownToPmMapping() {
if (!editor) return { charToPm: (n) => n, blockMap: [] };
let mapping = { charToPm: (n) => n, blockMap: [] };
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const doc = view.state.doc;
const serializer = ctx.get(serializerCtx);
const schema = view.state.schema;
const safeSerializer = (node) => {
try {
// Try direct serialization first
return serializer(node);
} catch (err) {
// If direct fails (e.g. Milkdown stack interactions), try wrapping in a temporary doc
try {
// Create a temp doc with just this node
// Note: This might add a trailing newline which standard blocks have anyway.
// But we want the block's own markdown.
const tempDoc = schema.topNodeType.create(null, node);
// Serialize the doc
let md = serializer(tempDoc);
// Removing trailing newline usually added by doc serialization
return md.trim();
} catch (e2) {
console.warn("[BlockMap] Serialization completely failed for node:", node, e2);
throw err; // Throw original
}
}
};
// Note: We use the original serializer for the full doc because it works on doc.
// But computeBlockMapping uses the passed serializer for individual blocks.
// We pass safeSerializer.
// For the "fullMarkdown" arg inside computeBlockMapping, we can just pass the doc.
// Wait, computeBlockMapping calls serializer(doc) inside.
// safeSerializer handles doc too (direct call).
mapping = computeBlockMapping(doc, safeSerializer);
});
return mapping;
}
/**
* Scroll the editor to make the range visible
*/
export function scrollToEditorRange(from, to) {
if (editor) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
try {
// Safeguard against out of bounds
const safeFrom = Math.min(from, view.state.doc.content.size);
// Get the DOM coordinates for the position
const coords = view.coordsAtPos(safeFrom);
if (coords) {
// Find the scroll container
const scrollContainer = document.querySelector('.editor-scroll') ||
document.querySelector('.milkdown') ||
view.dom.parentElement;
if (scrollContainer) {
// Calculate scroll position to center the target
const containerRect = scrollContainer.getBoundingClientRect();
const targetY = coords.top - containerRect.top + scrollContainer.scrollTop;
const centerOffset = containerRect.height / 3;
scrollContainer.scrollTo({
top: Math.max(0, targetY - centerOffset),
behavior: 'smooth'
});
}
}
// Also try ProseMirror's built-in scroll
const resolved = view.state.doc.resolve(safeFrom);
const tr = view.state.tr;
tr.setSelection(TextSelection.near(resolved));
tr.scrollIntoView();
view.dispatch(tr);
} catch (e) {
console.warn("Autoscroll failed:", e);
}
});
}
}
/**
* Helper: Find best relative match for text in editor.
* Uses Head/Tail anchoring and Gap detection to handle node boundaries robustly.
*/
/**
* Helper: Find best relative match for text in editor.
* Uses Head/Tail anchoring and Gap detection.
* @param {EditorView} view
* @param {string} text
* @param {number} relativePos - fallback ratio
* @param {number} [targetPmPos] - Precise PM position if available
*/
function findBestMatch(view, text, relativePos, targetPmPos) {
if (!text) return null;
// 1. Prepare Text
let cleanText = text.replace(/\s+/g, ' ').trim();
// Strip MD links/images [Text](Url) - handle empty url too
cleanText = cleanText.replace(/!?(?:\[([^\]]*)\])\([^\)]*\)/g, '$1');
// Strip simple formatting: * _ # ` ~ (also strikethrough)
cleanText = cleanText.replace(/[*_#`~]/g, '');
cleanText = cleanText.replace(/[\[\]\(\)]/g, '');
cleanText = cleanText.trim();
if (cleanText.length === 0) return null;
const SnippetLen = 40;
const searchHead = cleanText.substring(0, SnippetLen);
const searchTail = cleanText.slice(-SnippetLen);
const doc = view.state.doc;
const docSize = doc.content.size;
// 2. Build Virtual Text Stream & Candidate Map
let virtualText = "";
let lastEndPos = 0;
// nodeMap: { vStart, vEnd, pmStart }
const nodeMap = [];
doc.descendants((node, pos) => {
if (node.isText) {
// Check Gap
if (lastEndPos > 0 && pos - lastEndPos >= 2) {
virtualText += " "; // Block boundary
}
const vStart = virtualText.length;
virtualText += node.text;
const vEnd = virtualText.length;
nodeMap.push({ vStart, vEnd, pmStart: pos });
lastEndPos = pos + node.nodeSize;
}
});
// 6. Map Virtual Indices -> PM Positions (Moved up for use in scoring)
const mapToPM = (vIndex) => {
const node = nodeMap.find(n => vIndex >= n.vStart && vIndex <= n.vEnd);
if (node) {
const offset = vIndex - node.vStart;
return node.pmStart + offset;
}
const nextNode = nodeMap.find(n => n.vStart > vIndex);
if (nextNode) return nextNode.pmStart;
return docSize;
};
// 3. Find Matches in Virtual Text
const headMatches = [];
let idx = virtualText.indexOf(searchHead);
while (idx !== -1) {
headMatches.push(idx);
idx = virtualText.indexOf(searchHead, idx + 1);
}
if (headMatches.length === 0) {
// console.warn(`[PreviewDebug] Head NOT found: "${searchHead}"`);
return null;
}
// 4. Select Best Head
// If targetPmPos is provided, sort by distance to it (converted from candidate vIndex -> PM)
// Otherwise use estimated virtual index
if (targetPmPos !== undefined) {
headMatches.sort((a, b) => {
const posA = mapToPM(a);
const posB = mapToPM(b);
return Math.abs(posA - targetPmPos) - Math.abs(posB - targetPmPos);
});
} else {
const estimatedIndex = Math.floor(virtualText.length * relativePos);
headMatches.sort((a, b) => Math.abs(a - estimatedIndex) - Math.abs(b - estimatedIndex));
}
const bestHeadIndex = headMatches[0];
// 5. Find Tail
let bestTailIndex = -1;
if (cleanText.length <= SnippetLen) {
bestTailIndex = bestHeadIndex + cleanText.length;
} else {
const searchStart = bestHeadIndex + cleanText.length - SnippetLen - 20;
const tailIdx = virtualText.indexOf(searchTail, Math.max(bestHeadIndex, searchStart));
if (tailIdx !== -1 && (tailIdx - bestHeadIndex) < cleanText.length * 1.5) {
bestTailIndex = tailIdx + searchTail.length;
} else {
bestTailIndex = bestHeadIndex + cleanText.length;
}
}
const fromPos = mapToPM(bestHeadIndex);
const toPos = mapToPM(bestTailIndex);
return { from: fromPos, to: toPos };
}
/**
* Robust Text Finder for Highlighting.
*/
export function highlightByText(text, type, relativePos) {
if (!editor || !text) return;
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const match = findBestMatch(view, text, relativePos);
if (!match) return;
let from = match.from;
let to = match.to;
if (type === 'point') {
from = to;
to = Math.min(from + 1, view.state.doc.content.size);
if (from === to && from > 0) from = from - 1;
}
const tr = view.state.tr.setMeta(highlightKey, { type: 'set', from, to });
view.dispatch(tr);
setTimeout(() => scrollToEditorRange(from, to), 0);
});
}
/**
* Show Ghost Preview for a Hunk by simulating the change in plain text space.
* NOW USES COORDINATE MAPPING instead of text search for reliability.
* @param {string} hunkType - 'add', 'delete', or 'modify'
* @param {number} baseStart - Start position in markdown
* @param {number} baseEnd - End position in markdown
* @param {string} modifiedText - Text being added (for add/mod hunks)
* @param {string} markdownContent - The full markdown content
* @param {string} baseText - Text being deleted/replaced (for delete/mod hunks)
*/
export function previewHunkWithDiff(hunkType, baseStart, baseEnd, modifiedText, markdownContent, baseText) {
if (!editor) return;
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
// Use coordinate mapping - maps markdown offsets directly to PM positions
const { charToPm } = getMarkdownToPmMapping();
const operations = [];
if (hunkType === 'delete') {
// Map the delete range directly
const fromPm = charToPm(baseStart);
const toPm = charToPm(baseEnd);
if (fromPm < toPm) {
operations.push({
type: 'delete',
from: fromPm,
to: toPm
});
} else if (baseEnd > baseStart) {
// Ensure at least 1 char range for non-empty deletes
operations.push({
type: 'delete',
from: fromPm,
to: fromPm + 1
});
}
} else if (hunkType === 'add') {
// Map the insertion point directly
const posPm = charToPm(baseStart);
operations.push({
type: 'add',
text: modifiedText,
pos: posPm
});
} else {
// Modify = delete + add
const fromPm = charToPm(baseStart);
const toPm = charToPm(baseEnd);
// Delete the old text
if (fromPm < toPm) {
operations.push({
type: 'delete',
from: fromPm,
to: toPm
});
}
// Add the new text at the same position
if (modifiedText) {
operations.push({
type: 'add',
text: modifiedText,
pos: fromPm
});
}
}
if (operations.length > 0) {
showDiffPreview(operations);
// Auto-scroll to the hunk location
const scrollTarget = charToPm(baseStart);
setTimeout(() => scrollToEditorRange(scrollTarget, scrollTarget), 0);
} else {
console.warn(`[HunkPreview] No operations generated!`);
}
});
}
/**
* Show Ghost Preview for a Hunk using direct position mapping.
* This version takes a markdown position and converts it to PM position
* using the same logic as renderGhostPreview.
* @param {string} text - The text to insert, or the text being deleted.
* @param {string} kind - 'insert', 'delete', or 'replace'
* @param {number} markdownPos - Character position in the markdown string
* @param {string} markdownContent - The full markdown content
* @param {string} [deleteTextOrInsertText] - For replace: text being deleted. For insert: ignored.
*/
export function previewGhostHunkByPosition(text, kind, markdownPos, markdownContent, deleteTextOrInsertText) {
if (!editor) return;
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
// Import stripMarkdown dynamically to avoid circular deps
// Actually, we'll compute the position directly
// Get character-to-PM-position mapping (plain text from PM)
const { charToPm, pmText } = getCharToPmMapping();
// Strip markdown from the content up to markdownPos to find the plain text offset
// We need to find what plain text position corresponds to markdownPos in markdown
const prefixMarkdown = markdownContent.substring(0, markdownPos);
// Strip markdown from the prefix to get plain text length
const prefixPlain = stripMarkdown(prefixMarkdown);
// The plain text offset is the length of the stripped prefix
const plainOffset = prefixPlain.length;
let from, to;
let deleteFrom, deleteTo;
if (kind === 'insert') {
// Insert at the plain text offset
const posPm = charToPm(plainOffset);
from = posPm;
to = posPm;
} else if (kind === 'delete') {
// Delete: we need the range of text being deleted
const deleteTextPlain = stripMarkdown(text);
const fromPm = charToPm(plainOffset);
const toPm = charToPm(plainOffset + deleteTextPlain.length);
from = fromPm;
to = toPm;
} else if (kind === 'replace') {
// Replace: delete the old text and insert new text at that position
const deleteTextPlain = stripMarkdown(deleteTextOrInsertText || '');
deleteFrom = charToPm(plainOffset);
deleteTo = charToPm(plainOffset + deleteTextPlain.length);
}
const tr = view.state.tr.setMeta(highlightKey, {
type: 'preview',
kind,
from,
to,
deleteFrom,
deleteTo,
text // text to insert
});
view.dispatch(tr);
// Scroll target
const scrollTarget = (kind === 'replace') ? deleteFrom : from;
if (scrollTarget !== undefined) {
setTimeout(() => scrollToEditorRange(scrollTarget, scrollTarget), 0);
}
});
}
/**
* Show Ghost Preview for a Hunk
* @param {string} text - The text to insert, or the text being deleted.
* @param {string} kind - 'insert' or 'delete'
* @param {number} relativePos - Position heuristic
* @param {string} [contextOrDeleteText] - For insert: context. For replace: text to delete.
*/
export function previewGhostHunk(text, kind, relativePos, contextOrDeleteText) {
if (!editor) return;
// Use Serializer Map to get precise estimation
const mapping = getMarkdownToPmMapping();
const content = getMarkdown();
const estimatedPmPos = mapping.charToPm(Math.floor(relativePos * content.length));
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
let from, to;
let deleteFrom, deleteTo;
if (kind === 'insert') {
const context = contextOrDeleteText;
if (!context) {
from = 0;
} else {
// Pass estimatedPmPos to findBestMatch
const match = findBestMatch(view, context, relativePos, estimatedPmPos);
if (match) {
// Insert AFTER the context
from = match.to; // User precise end of context
} else {
from = estimatedPmPos;
}
}
to = from;
} else if (kind === 'delete') {
const match = findBestMatch(view, text, relativePos, estimatedPmPos);
if (match) {
from = match.from;
to = match.to;
} else {
return;
}
} else if (kind === 'replace') {
const deleteText = contextOrDeleteText;
if (deleteText) {
const match = findBestMatch(view, deleteText, relativePos, estimatedPmPos);
if (match) {
deleteFrom = match.from;
deleteTo = match.to;
} else {
return;
}
}
}
const tr = view.state.tr.setMeta(highlightKey, {
type: 'preview',
kind,
from,
to,
deleteFrom,
deleteTo,
text
});
view.dispatch(tr);
// Scroll target
const scrollTarget = (kind === 'replace') ? deleteFrom : from;
if (scrollTarget !== undefined) {
setTimeout(() => scrollToEditorRange(scrollTarget, scrollTarget), 0);
}
});
}
// ... (other exports)
// ...
// ---------------------------------------------------------------------------
// Initialize profile and Yjs document state before creating the editor.
// ---------------------------------------------------------------------------
export let editor;
export function getEditorContent() {
let content = "";
if (editor) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
// Use textBetween instead of textContent to preserve newlines
const doc = view.state.doc;
content = doc.textBetween(0, doc.content.size, "\n", "\n");
});
}
return content;
}
/**
* Get the current document content as Markdown.
* This extracts markdown on-demand using Milkdown's serializer.
*/
export function getMarkdown() {
let markdown = "";
if (editor) {
try {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const serializer = ctx.get(serializerCtx);
markdown = serializer(view.state.doc);
});
} catch (err) {
console.error("[ERROR] getMarkdown serialization failed:", err);
}
}
return markdown;
}
/**
* Serialize a single ProseMirror node to Markdown.
* Useful for calculating precise offset mappings.
* @param {Node} node - The ProseMirror node to serialize
* @returns {string} The markdown string
*/
export function serializeNode(node) {
let markdown = "";
if (editor) {
try {
editor.action((ctx) => {
const serializer = ctx.get(serializerCtx);
// The serializer usually expects a Node.
markdown = serializer(node);
});
} catch (err) {
console.error("[ERROR] serializeNode failed:", err);
}
}
return markdown;
}
/**
* Undo the last change (uses Yjs undo stack).
*/
export function doUndo() {
if (editor) {
try {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
undo(view.state, view.dispatch);
});
} catch (err) {
console.error("Undo error:", err);
}
}
}
/**
* Redo the last undone change (uses Yjs redo stack).
*/
export function doRedo() {
if (editor) {
try {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
redo(view.state, view.dispatch);
});
} catch (err) {
console.error("Redo error:", err);
}
}
}
/**
* Pre-process markdown to convert pandoc-specific syntax to standard HTML.
* Converts spans like [text]{.underline} to <u>text</u>
* @param {string} markdown - Raw markdown from pandoc
* @returns {string} Processed markdown
*/
function preprocessMarkdown(markdown) {
if (!markdown) return markdown;
// Note: [text]{.underline} and ++text++ are handled by the underline remark plugin
// Convert pandoc strikethrough spans: [text]{.strikethrough} -> ~~text~~
let processed = markdown.replace(/\[([^\]]+)\]\{\.strikethrough\}/g, '~~$1~~');
return processed;
}
/**
* Set the editor content from a markdown string.
* This properly parses the markdown and renders it in WYSIWYG mode.
* Pre-processes pandoc-specific syntax to standard markdown/HTML.
* @param {string} markdown - The markdown content to set
* @returns {boolean} True if successful
*/
export function setMarkdownContent(markdown) {
if (!editor) {
console.error("setMarkdownContent: Editor not initialized");
return false;
}
if (!markdown || typeof markdown !== 'string') {
console.warn("setMarkdownContent: No valid markdown provided");
return false;
}
try {
// Pre-process pandoc syntax before loading
const processed = preprocessMarkdown(markdown);
// Rebuild figure registry to assign correct numbers
rebuildFigureRegistry(processed);
editor.action(replaceAll(processed));
return true;
} catch (err) {
console.error("setMarkdownContent error:", err);
return false;
}
}
export async function initEditor() {
try {
await initProfile();
} catch (err) {
console.warn("Failed to initialize profile, using defaults:", err);
}
// Initial load is handled by the activeChange listener in main.js/editor.js
// or by checking active document if listener hasn't fired yet.
// However, to avoid race conditions with the listener, we should rely on the listener
// or explicitly check if we need to load ONLY if not already loaded.
// For now, we'll let the listener handle it to avoid double-loading.
enablePersistence();
// ---------------------------------------------------------------------------
// Create the Milkdown editor with Yjs + semantic patch logging.
// ---------------------------------------------------------------------------
try {
editor = await Editor.make()
.config((ctx) => {
ctx.set(rootCtx, document.getElementById("editor"));
ctx.set(defaultValueCtx, "");
ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
// This ensures Markdown export stays in sync.
const event = new CustomEvent("markdown-updated", {
detail: { markdown, prevMarkdown },
});
window.dispatchEvent(event);
});
})
.use(commonmark)
.use(gfm)
.use(underlinePlugin)
.use(figurePlugin)
.use(listener)
.create();
} catch (err) {
console.error("Editor: Failed to create", err);
return;
}
// ---------------------------------------------------------------------------
// Patch logger plugin (semantic patches + grouping).
// ---------------------------------------------------------------------------
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const state = view.state;
const patchLoggerPlugin = new Plugin({
appendTransaction(transactions, oldState, newState) {
if (!transactions.length) return;
// Skip patch recording when we're applying updates (e.g., restoring from patch)
if (isApplyingUpdate()) {
return;
}
const semanticPatches = [];
for (const tr of transactions) {
// Skip Yjs-generated transactions (mirror updates)
if (tr.getMeta("y-sync$")) {
// console.log("Skipping Yjs transaction");
continue;
}
for (const step of tr.steps) {
const semantic = stepToSemanticPatch(step, oldState, newState);
semanticPatches.push(semantic);
}
}
if (semanticPatches.length === 0) return;
// Feed semantic patches into the grouper (author is now pulled from profile)
// Use getMarkdown() to preserve formatting in snapshots
const currentText = getMarkdown();
const groupedRecord = addSemanticPatches(semanticPatches, currentText);
// Only when the grouper flushes do we persist to SQLite
if (groupedRecord) {
// Try to record to active document, fall back to global
const docId = getActiveDocumentId();
if (docId) {
invoke("record_document_patch", { id: docId, patch: groupedRecord }).catch((err) => {
console.error("Failed to record document patch:", err);
// Fall back to global patch log
invoke("record_patch", { patch: groupedRecord }).catch(() => { });
});
} else {
invoke("record_patch", { patch: groupedRecord }).catch((err) => {
console.error("Failed to record grouped patch:", err);