fix(editor): copy editor text with CRLF line endings on Windows

This commit is contained in:
Thanadon Kongkanun authored and GitHub committed 2026-09-24 12:09:10 +08:00
1 parent 417ce09de1
commit 42a1949bdc
5 files changed
+81

No files matched your search

@@ -104,6 +104,7 @@ import { buildSqlShortcutExecutionSql, enabledSqlShortcutActions, resolveSqlShor
import { resolveSqlShortcutTableToken } from "@/lib/sql/sqlShortcutTableTarget";
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings";
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
@@ -1841,6 +1842,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
}),
sqlBlockFoldService,
drawSelection(),
editorClipboardLineEndingsExtension(EditorView),
trimmedSelectionLayer(),
selectionMatchOccurrences(),
dropCursor(),
@@ -8,6 +8,7 @@ import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
@@ -208,6 +209,7 @@ async function initDdlEditor(content: string) {
},
}),
basicSetup,
editorClipboardLineEndingsExtension(EditorView),
EditorState.allowMultipleSelections.of(true),
langSql.sql({ dialect }),
themeExt,
@@ -7,6 +7,7 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { bracketMatching, foldGutter, foldKeymap } from "@codemirror/language";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, cellDetailActiveLineColor, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings";
import { shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { useSettingsStore } from "@/stores/settingsStore";
import { CELL_DETAIL_JSON_FORMAT_MAX_LENGTH, isJsonColumnType } from "@/lib/dataGrid/cellDetailPresentation";
@@ -217,6 +218,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
highlightSpecialChars(),
history(),
drawSelection(),
editorClipboardLineEndingsExtension(EditorView),
trimmedSelectionLayer(),
dropCursor(),
highlightActiveLine(),
@@ -0,0 +1,49 @@
import { strict as assert } from "node:assert";
import { afterEach, test, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings";
const SQL = "SELECT 1\nFROM t\nWHERE a = 1;";
/** Run the document through the filters CodeMirror applies to copied text. */
function copiedText(state: EditorState, text: string): string {
return state.facet(EditorView.clipboardOutputFilter).reduce((value, filter) => filter(value, state), text);
}
function stateWithExtension(): EditorState {
return EditorState.create({ doc: SQL, extensions: [editorClipboardLineEndingsExtension(EditorView)] });
}
afterEach(() => {
vi.unstubAllGlobals();
});
function stubPlatform(userAgent: string) {
vi.stubGlobal("navigator", { userAgent });
}
test("copied editor text uses CRLF on Windows", () => {
stubPlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
assert.equal(copiedText(stateWithExtension(), SQL), "SELECT 1\r\nFROM t\r\nWHERE a = 1;");
});
test("the document itself keeps LF", () => {
stubPlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
assert.equal(stateWithExtension().doc.toString(), SQL);
});
test("copied editor text keeps LF on macOS", () => {
stubPlatform("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)");
assert.equal(copiedText(stateWithExtension(), SQL), SQL);
});
test("single-line text is unchanged on Windows", () => {
stubPlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
assert.equal(copiedText(stateWithExtension(), "SELECT 1;"), "SELECT 1;");
});
test("a view without the facet contributes no extension", () => {
const state = EditorState.create({ doc: SQL, extensions: [editorClipboardLineEndingsExtension({})] });
assert.deepEqual(state.facet(EditorView.clipboardOutputFilter), []);
});
@@ -0,0 +1,26 @@
import type { Extension } from "@codemirror/state";
import { clipboardLineEndings } from "@/lib/common/clipboard";
type EditorViewWithClipboardFilter = {
clipboardOutputFilter?: { of(filter: (text: string) => string): Extension };
};
/**
* Give text copied out of a CodeMirror editor the same line endings as every other DBX
* copy path.
*
* `copyToClipboard` rewrites line endings to CRLF on Windows, but CodeMirror serves
* Ctrl+C/Ctrl+X itself and writes `text/plain` straight from the document, which always
* uses `\n`. Windows native edit controls (the Win32 EDIT control, and the WinForms apps
* built on it) treat only CRLF as a line break, so a multi-line statement copied from an
* editor arrives there as a single line.
*
* `EditorView.clipboardOutputFilter` is CodeMirror's own hook for copy/cut/drag text, so
* the document keeps using `\n` and only what leaves the editor is rewritten.
*/
export function editorClipboardLineEndingsExtension(editorView: EditorViewWithClipboardFilter | null | undefined): Extension {
const facet = editorView?.clipboardOutputFilter;
// The facet arrived in @codemirror/view 6.32; without it the copy path keeps LF.
if (!facet) return [];
return facet.of((text) => clipboardLineEndings(text));
}