Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ import { configureAppRuntime, createDefaultAppRuntime, resetAppRuntimeForTests }
import { showAppToast } from "./lib/app-toast";
import { createShardedTest } from "./test/shard";

const scheduleMarkdownSourceEditorPreloadMock = vi.hoisted(() => vi.fn(() => vi.fn()));

vi.mock("./components/LazyMarkdownSourceEditor", async (importOriginal) => {
const actual = await importOriginal<typeof import("./components/LazyMarkdownSourceEditor")>();

return {
...actual,
scheduleMarkdownSourceEditorPreload: scheduleMarkdownSourceEditorPreloadMock
};
});

installAppTestHarness();

// Vitest shards files only, so CI needs a local registration boundary to split this monolithic suite by test title.
Expand Down Expand Up @@ -617,6 +628,15 @@ describe("Markra workspace", () => {
expect(shell).toHaveClass("overscroll-none");
});

it("schedules source editor preloading after the visual editor is ready", async () => {
scheduleMarkdownSourceEditorPreloadMock.mockClear();

const { container } = renderApp();

await waitFor(() => expect(container.querySelector(".cm-editor")).toBeInTheDocument());
await waitFor(() => expect(scheduleMarkdownSourceEditorPreloadMock).toHaveBeenCalled());
});

it("imports local images through the native file menu without replacing manual image insertion", async () => {
const localImage = new File([new Uint8Array([1, 2, 3])], "Local Diagram.png", { type: "image/png" });
mockedConsumeWelcomeDocumentState.mockResolvedValue(false);
Expand Down Expand Up @@ -6531,6 +6551,53 @@ describe("Markra workspace", () => {
expect(container.querySelectorAll(".cm-markra-empty-line")).toHaveLength(1);
});

it("preserves the active selection and editor focus across visual and source modes", async () => {
const syntheticContent = "# Synthetic cursor\n\nalpha beta gamma\n\nomega";
mockOpenMarkdownFile({
content: syntheticContent,
name: "synthetic.md",
path: mockNativePath
});
renderApp();

fireEvent.keyDown(window, { key: "o", metaKey: true });
await expectVisibleMarkdownText("Synthetic cursor");

const visualEditor = screen.getByRole("textbox", { name: "Markdown document" });
const visualView = getMarkdownSourceView(visualEditor);
const visualCursor = syntheticContent.indexOf("beta") + 2;
const visualSelection = EditorSelection.single(visualCursor, syntheticContent.indexOf("alpha"));
act(() => {
visualView.dispatch({ selection: visualSelection });
});

await selectEditorViewMode("Source code");

const sourceEditor = await screen.findByRole("textbox", { name: "Markdown source" });
const sourceView = getMarkdownSourceView(sourceEditor);
await waitFor(() => {
expect(sourceView.state.selection.eq(visualSelection)).toBe(true);
expect(sourceEditor).toHaveFocus();
});

const sourceCursor = syntheticContent.indexOf("omega") + 3;
const sourceSelection = EditorSelection.single(sourceCursor, syntheticContent.indexOf("gamma"));
act(() => {
sourceView.dispatch({ selection: sourceSelection });
});
const requestMeasureSpy = vi.spyOn(visualView, "requestMeasure");
requestMeasureSpy.mockClear();

await selectEditorViewMode("Preview");

await waitFor(() => {
expect(visualView.state.selection.eq(sourceSelection)).toBe(true);
expect(visualEditor).toHaveFocus();
expect(requestMeasureSpy).toHaveBeenCalled();
});
requestMeasureSpy.mockRestore();
});

it("commits pending visual IME content before source mode mounts", async () => {
renderApp();

Expand Down
114 changes: 112 additions & 2 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
type MarkdownExportSnapshot
} from "./components/MarkdownExportDocument";
import { MarkdownPaper } from "./components/MarkdownPaper";
import { LazyMarkdownSourceEditor } from "./components/LazyMarkdownSourceEditor";
import {
LazyMarkdownSourceEditor,
scheduleMarkdownSourceEditorPreload
} from "./components/LazyMarkdownSourceEditor";
import {
MarkdownTabsBar,
markdownTabDragDataType,
Expand Down Expand Up @@ -90,6 +93,7 @@ import {
useSettingsWindowShortcut
} from "./hooks/useNativeBindings";
import type { EditorView } from "@codemirror/view";
import { EditorSelection } from "@codemirror/state";
import {
aiTranslationLanguageName,
clampNumber,
Expand Down Expand Up @@ -289,7 +293,15 @@ export async function refreshImportedAttachmentTree(refreshTree: () => Promise<u
type AiQuickActionIntent = Exclude<AiEditIntent, "custom">;
type EditorMode = "source" | "split" | "visual";
type EditorSurface = "source" | "visual";
type EditorSelectionSnapshot = {
mainIndex: number;
ranges: Array<{
anchor: number;
head: number;
}>;
};
type DocumentTabViewState = {
selection?: EditorSelectionSnapshot;
sourceScrollTop?: number;
visualScrollTop?: number;
};
Expand All @@ -298,6 +310,29 @@ type PendingEditorModeScroll = {
tabId: string;
targetSurface: EditorSurface;
};
type PendingEditorModeSelection = {
selection: EditorSelectionSnapshot;
tabId: string;
targetSurface: EditorSurface;
};

function boundedEditorSelection(selection: EditorSelectionSnapshot, documentLength: number) {
const ranges = selection.ranges.length > 0
? selection.ranges.map((range) => EditorSelection.range(
Math.max(0, Math.min(documentLength, range.anchor)),
Math.max(0, Math.min(documentLength, range.head))
))
: [EditorSelection.cursor(0)];

return EditorSelection.create(ranges, Math.max(0, Math.min(ranges.length - 1, selection.mainIndex)));
}

function editorSelectionSnapshot(selection: EditorSelection): EditorSelectionSnapshot {
return {
mainIndex: selection.mainIndex,
ranges: selection.ranges.map(({ anchor, head }) => ({ anchor, head }))
};
}

export { runEditorLinkCommand } from "./app/editor-link-command";
export { globalSearchDebounceMs } from "./hooks/useWorkspaceSearch";
Expand Down Expand Up @@ -481,6 +516,7 @@ function WorkspaceApp() {
const [splitVisualPanePercent, setSplitVisualPanePercent] = useState(defaultSplitVisualPanePercent);
const [sideDocumentMainPanePercent, setSideDocumentMainPanePercent] = useState(defaultSideDocumentMainPanePercent);
const [editorTabDropTargetActive, setEditorTabDropTargetActive] = useState(false);
const [sourceEditorReadySequence, setSourceEditorReadySequence] = useState(0);
const [visualEditorReadySequence, setVisualEditorReadySequence] = useState(0);
const [exportSnapshot, setExportSnapshot] = useState<MarkdownExportSnapshot | null>(null);
const sourceMode = editorMode === "source";
Expand All @@ -505,10 +541,12 @@ function WorkspaceApp() {
});
const largeMarkdownVisualBlockedRef = useRef(false);
const mainDocumentPaneRef = useRef<HTMLDivElement | null>(null);
const sourceEditorRef = useRef<EditorView | null>(null);
const sourceScrollRef = useRef<HTMLElement | null>(null);
const visualScrollRef = useRef<HTMLElement | null>(null);
const mainVisualEditorsRef = useRef(new Map<string, EditorView>());
const documentTabViewStatesRef = useRef(new Map<string, DocumentTabViewState>());
const pendingEditorModeSelectionRef = useRef<PendingEditorModeSelection | null>(null);
const pendingEditorModeScrollRef = useRef<PendingEditorModeScroll | null>(null);
const splitSurfaceRef = useRef<HTMLDivElement | null>(null);
const sideDocumentSurfaceRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -902,6 +940,14 @@ function WorkspaceApp() {
handleVisualEditorReady,
rememberMarkdownTabVisualBaseline
]);
const handleSourceEditorReady = useCallback((readyEditor: EditorView | null, disposedEditor?: EditorView) => {
if (readyEditor) {
sourceEditorRef.current = readyEditor;
setSourceEditorReadySequence((current) => current + 1);
} else if (sourceEditorRef.current === disposedEditor) {
sourceEditorRef.current = null;
}
}, []);
useEffect(() => {
if (!activeTabId) {
handleVisualEditorReady(null);
Expand Down Expand Up @@ -1285,6 +1331,16 @@ function WorkspaceApp() {
if (activeImageFile || !activeTabId) return;

const nextState: DocumentTabViewState = {};
const activeEditor = editorMode === "source"
? sourceEditorRef.current
: editorMode === "visual"
? mainVisualEditorsRef.current.get(activeTabId) ?? null
: activeEditorSurface === "source"
? sourceEditorRef.current
: mainVisualEditorsRef.current.get(activeTabId) ?? null;
if (activeEditor) {
nextState.selection = editorSelectionSnapshot(activeEditor.state.selection);
}
if (editorMode === "visual" && visualScrollRef.current) {
nextState.visualScrollTop = visualScrollRef.current.scrollTop;
} else if (editorMode === "source" && sourceScrollRef.current) {
Expand All @@ -1297,7 +1353,20 @@ function WorkspaceApp() {
}
}
if (Object.keys(nextState).length > 0) saveDocumentTabViewState(activeTabId, nextState);
return nextState.selection;
}, [activeEditorSurface, activeImageFile, activeTabId, editorMode, saveDocumentTabViewState]);
const queueEditorModeSelection = useCallback((
targetSurface: EditorSurface,
selection: EditorSelectionSnapshot | undefined
) => {
if (!activeTabId || !selection) return;

pendingEditorModeSelectionRef.current = {
selection,
tabId: activeTabId,
targetSurface
};
}, [activeTabId]);
const queueEditorModeScroll = useCallback((targetSurface: EditorSurface) => {
if (!activeTabId) return;

Expand Down Expand Up @@ -2931,6 +3000,13 @@ function WorkspaceApp() {
const titleDocumentKind = activeImageFile ? "image" : hasOpenDocument ? "file" : "folder";
const sourceModeAvailable = hasOpenDocument && !activeImageFile;
const supportsAiThinking = selectedInlineAiModel?.capabilities.includes("reasoning") ?? false;
useEffect(() => {
if (editorMode !== "visual" || !sourceModeAvailable || !activeTabId) return;
// Keep visual editor setup on the startup path; warm the source chunk only after it is usable.
if (!mainVisualEditorsRef.current.has(activeTabId)) return;

return scheduleMarkdownSourceEditorPreload();
}, [activeTabId, editorMode, sourceModeAvailable, visualEditorReadySequence]);
useEffect(() => {
if (activeEditorSurface !== "source") return;

Expand Down Expand Up @@ -3568,14 +3644,15 @@ function WorkspaceApp() {
if (!sourceModeAvailable) return;
if (nextMode === editorMode) return;

captureActiveDocumentViewState();
const selection = captureActiveDocumentViewState();
// IME changes can still be pending in the visual surface when source mode
// mounts, so snapshot the originating editor before changing surfaces.
commitActiveVisualMarkdown();

if (nextMode === "visual") {
if (sourceMode) syncSourceEditsToVisualHistory();
queueEditorModeScroll("visual");
queueEditorModeSelection("visual", selection);
setEditorMode("visual");
setActiveEditorSurface("visual");
return;
Expand All @@ -3585,6 +3662,7 @@ function WorkspaceApp() {
updateActiveAiSelection(null);
handleAiCommandClose();
queueEditorModeScroll("source");
queueEditorModeSelection("source", selection);
setEditorMode("source");
setActiveEditorSurface("source");
return;
Expand All @@ -3593,6 +3671,7 @@ function WorkspaceApp() {
updateActiveAiSelection(null);
handleAiCommandClose();
if (sideDocumentGroup) clearSideDocumentGroup();
queueEditorModeSelection(sourceMode ? "source" : "visual", selection);
setEditorMode("split");
setActiveEditorSurface(sourceMode ? "source" : "visual");
}, [
Expand All @@ -3602,6 +3681,7 @@ function WorkspaceApp() {
editorMode,
handleAiCommandClose,
queueEditorModeScroll,
queueEditorModeSelection,
sideDocumentGroup,
sourceMode,
sourceModeAvailable,
Expand Down Expand Up @@ -3827,18 +3907,37 @@ function WorkspaceApp() {
)) {
pendingEditorModeScrollRef.current = null;
}

const pendingSelection = pendingEditorModeSelectionRef.current;
const targetSurface = editorMode === "split" ? activeEditorSurface : editorMode;
if (pendingSelection?.tabId === activeTabId && pendingSelection.targetSurface === targetSurface) {
const targetEditor = targetSurface === "source"
? sourceEditorRef.current
: mainVisualEditorsRef.current.get(activeTabId) ?? null;
if (targetEditor) {
const selection = boundedEditorSelection(pendingSelection.selection, targetEditor.state.doc.length);
// CodeMirror may measure while its visual surface is hidden; refresh it only after React reveals the target.
targetEditor.requestMeasure();
targetEditor.dispatch({ selection, scrollIntoView: true });
targetEditor.focus();
saveDocumentTabViewState(activeTabId, { selection: editorSelectionSnapshot(selection) });
pendingEditorModeSelectionRef.current = null;
}
}
});

return () => {
window.cancelAnimationFrame(restoreFrame);
};
}, [
activeImageFile,
activeEditorSurface,
activeTabId,
document.revision,
editorMode,
hasOpenDocument,
saveDocumentTabViewState,
sourceEditorReadySequence,
visualEditorReadySequence
]);
const aiAgentContext = useMemo(() => ({
Expand Down Expand Up @@ -4267,6 +4366,13 @@ function WorkspaceApp() {
]
);
const mainVisualEditorTabs = documentTabs.filter((tab) => tab.open);
const pendingSourceSelection = pendingEditorModeSelectionRef.current;
const activeSourceInitialSelection = pendingSourceSelection?.tabId === activeTabId
&& pendingSourceSelection.targetSurface === "source"
? pendingSourceSelection.selection
: activeTabId
? documentTabViewStatesRef.current.get(activeTabId)?.selection
: undefined;
const mainVisualEditors = (
<>
{mainVisualEditorTabs.map((tab) => {
Expand Down Expand Up @@ -4727,6 +4833,7 @@ function WorkspaceApp() {
contentWidthPx={activeEditorContentWidthPx}
editorFontFamily={editorPreferences.preferences.editorFontFamily}
extendedSyntax={editorPreferences.preferences.extendedSyntax}
initialSelection={activeSourceInitialSelection}
language={appLanguage.language}
lineHeight={editorPreferences.preferences.lineHeight}
onChange={(content) => handleSourceMarkdownTabChange(
Expand All @@ -4738,6 +4845,7 @@ function WorkspaceApp() {
)}
onContentWidthChange={editorWidthResizerVisible ? handleEditorContentWidthChange : undefined}
onContentWidthResizeEnd={editorWidthResizerVisible ? handleEditorContentWidthResizeEnd : undefined}
onEditorReady={handleSourceEditorReady}
onScroll={handleSourcePaneScroll}
onSelectionTextChange={updateSelectedWordCount}
readOnly={readOnlyMode}
Expand Down Expand Up @@ -4765,6 +4873,7 @@ function WorkspaceApp() {
contentWidthPx={activeEditorContentWidthPx}
editorFontFamily={editorPreferences.preferences.editorFontFamily}
extendedSyntax={editorPreferences.preferences.extendedSyntax}
initialSelection={activeSourceInitialSelection}
language={appLanguage.language}
lineHeight={editorPreferences.preferences.lineHeight}
onChange={(content) => handleSourceMarkdownTabChange(
Expand All @@ -4776,6 +4885,7 @@ function WorkspaceApp() {
)}
onContentWidthChange={editorWidthResizerVisible ? handleEditorContentWidthChange : undefined}
onContentWidthResizeEnd={editorWidthResizerVisible ? handleEditorContentWidthResizeEnd : undefined}
onEditorReady={handleSourceEditorReady}
onScroll={handleSourcePaneScroll}
onSelectionTextChange={updateSelectedWordCount}
readOnly={readOnlyMode}
Expand Down
Loading
Loading