mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
Preserve cursor position when toggling markdown source editor (#125)
This commit is contained in:
@@ -2981,11 +2981,63 @@
|
|||||||
// Only steal focus when navigating (Enter/Shift+Enter), not while typing
|
// Only steal focus when navigating (Enter/Shift+Enter), not while typing
|
||||||
if (focusTextarea) sourceElement.focus();
|
if (focusTextarea) sourceElement.focus();
|
||||||
sourceElement.setSelectionRange(match.from, match.to);
|
sourceElement.setSelectionRange(match.from, match.to);
|
||||||
// Scroll the match into view
|
scrollSourceToOffset(match.from);
|
||||||
const linesBefore = sourceContent.substring(0, match.from).split('\n').length;
|
}
|
||||||
|
|
||||||
|
// Centre the source textarea's viewport on the line containing `offset`.
|
||||||
|
function scrollSourceToOffset(offset: number) {
|
||||||
|
if (!sourceElement) return;
|
||||||
|
const linesBefore = sourceContent.substring(0, offset).split('\n').length;
|
||||||
const lineHeight = parseFloat(getComputedStyle(sourceElement).lineHeight) || 20;
|
const lineHeight = parseFloat(getComputedStyle(sourceElement).lineHeight) || 20;
|
||||||
const targetScroll = (linesBefore - 1) * lineHeight - sourceElement.clientHeight / 2;
|
sourceElement.scrollTop = Math.max(0, (linesBefore - 1) * lineHeight - sourceElement.clientHeight / 2);
|
||||||
sourceElement.scrollTop = Math.max(0, targetScroll);
|
}
|
||||||
|
|
||||||
|
// ── Caret carry-over between the rich editor and the markdown source view (issue #125) ──
|
||||||
|
// The caret is a ProseMirror doc position in one mode and a character offset in the other,
|
||||||
|
// and the markdown<->doc conversion is lossy, so rather than invert the conversion we anchor
|
||||||
|
// on VISIBLE TEXT. `scanAlign` walks the markdown source against the doc's plain text (its
|
||||||
|
// non-whitespace characters); markup characters don't match the next expected visible char and
|
||||||
|
// are skipped, so the scan locks onto real words and self-resynchronises. This stays correct
|
||||||
|
// even at the end of a long note and is decoupled from how serialization happens to format.
|
||||||
|
// - stopAtNw >= 0: stop once that many visible chars have matched -> srcOffset is the source
|
||||||
|
// position just past them (maps a doc caret to a source offset).
|
||||||
|
// - limit >= 0: stop at source index `limit` -> nwCount is how many visible chars precede it
|
||||||
|
// (maps a source caret to a doc visible-char count).
|
||||||
|
function scanAlign(source: string, docNonWs: string, opts: { limit?: number; stopAtNw?: number }): { srcOffset: number; nwCount: number } {
|
||||||
|
const limit = opts.limit ?? -1;
|
||||||
|
const stopAtNw = opts.stopAtNw ?? -1;
|
||||||
|
if (stopAtNw === 0) return { srcOffset: 0, nwCount: 0 };
|
||||||
|
let k = 0;
|
||||||
|
let i = 0;
|
||||||
|
for (; i < source.length; i++) {
|
||||||
|
if (limit >= 0 && i >= limit) break;
|
||||||
|
const c = source[i];
|
||||||
|
if (c === ' ' || c === '\n' || c === '\t' || c === '\r') continue;
|
||||||
|
if (c === docNonWs[k]) {
|
||||||
|
k++;
|
||||||
|
if (stopAtNw >= 0 && k >= stopAtNw) { i++; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { srcOffset: i, nwCount: k };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole editor doc as plain text with whitespace removed (the alignment alphabet).
|
||||||
|
function docNonWhitespace(): string {
|
||||||
|
if (!editor) return '';
|
||||||
|
return editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n', '').replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor doc position just after the `targetNw`-th non-whitespace character.
|
||||||
|
function docPosForNonWsCount(doc: any, targetNw: number): number {
|
||||||
|
if (targetNw <= 0) return 0;
|
||||||
|
const nwAt = (pos: number) => doc.textBetween(0, pos, '\n', '').replace(/\s/g, '').length;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = doc.content.size;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (nwAt(mid) >= targetNw) hi = mid; else lo = mid + 1;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSourceHistory(content: string) {
|
function resetSourceHistory(content: string) {
|
||||||
@@ -4776,27 +4828,53 @@
|
|||||||
if (!loadedPath) return;
|
if (!loadedPath) return;
|
||||||
|
|
||||||
if (isSource && !lastSourceMode) {
|
if (isSource && !lastSourceMode) {
|
||||||
// Switching TO source: extract markdown from editor
|
// Switching TO source: extract markdown, then drop the caret on the same word (#125).
|
||||||
|
const caretNonWs = editor
|
||||||
|
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
|
||||||
|
: 0;
|
||||||
|
const docNonWs = docNonWhitespace();
|
||||||
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
||||||
resetSourceHistory(sourceContent);
|
resetSourceHistory(sourceContent);
|
||||||
lastSourceMode = true;
|
lastSourceMode = true;
|
||||||
|
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
|
||||||
|
tick().then(() => {
|
||||||
|
if (!sourceElement) return;
|
||||||
|
if (!isMobile) sourceElement.focus();
|
||||||
|
sourceElement.setSelectionRange(target, target);
|
||||||
|
scrollSourceToOffset(target);
|
||||||
|
});
|
||||||
} else if (!isSource && lastSourceMode) {
|
} else if (!isSource && lastSourceMode) {
|
||||||
lastSourceMode = false;
|
lastSourceMode = false;
|
||||||
|
// Capture the source caret before the textarea is torn down (#125).
|
||||||
|
const srcOffset = sourceElement ? sourceElement.selectionStart : 0;
|
||||||
|
const srcText = sourceContent;
|
||||||
|
// Re-anchor the caret in the rich editor once it holds the new content.
|
||||||
|
const restoreRichCaret = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
const nwBefore = scanAlign(srcText, docNonWhitespace(), { limit: srcOffset }).nwCount;
|
||||||
|
const pos = Math.min(docPosForNonWsCount(editor.state.doc, nwBefore), editor.state.doc.content.size);
|
||||||
|
const sel = TextSelection.near(editor.state.doc.resolve(pos));
|
||||||
|
editor.view.dispatch(editor.state.tr.setSelection(sel).scrollIntoView());
|
||||||
|
if (!isMobile) editor.view.focus();
|
||||||
|
requestAnimationFrame(() => editor?.commands.scrollIntoView());
|
||||||
|
};
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// Mobile: editor stays in DOM, just update its content
|
// Mobile: editor stays in DOM, just update its content
|
||||||
const content = sourceContent || ($activeNote?.content ?? '');
|
const content = srcText || ($activeNote?.content ?? '');
|
||||||
if (editor) {
|
if (editor) {
|
||||||
ignoreNextUpdate = true;
|
ignoreNextUpdate = true;
|
||||||
editor.commands.setContent(markdownToHtml(content));
|
editor.commands.setContent(markdownToHtml(content));
|
||||||
|
tick().then(restoreRichCaret);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Desktop: destroy old editor (its DOM element is gone),
|
// Desktop: destroy old editor (its DOM element is gone),
|
||||||
// wait for DOM to swap textarea→div, then create editor on new element.
|
// wait for DOM to swap textarea→div, then create editor on new element.
|
||||||
destroyEditor();
|
destroyEditor();
|
||||||
const content = sourceContent || ($activeNote?.content ?? '');
|
const content = srcText || ($activeNote?.content ?? '');
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (editorElement && !editor) {
|
if (editorElement && !editor) {
|
||||||
createEditor(content);
|
createEditor(content);
|
||||||
|
restoreRichCaret();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user