feat: use codemirror for cell detail editor

This commit is contained in:
t8y2 2026-05-26 21:02:01 +08:00
parent 2038501b53
commit 366756333a
4 changed files with 246 additions and 13 deletions

View File

@ -158,10 +158,13 @@ import { useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
import { useDataGridEditor } from "@/composables/useDataGridEditor";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
const { t } = useI18n();
const settingsStore = useSettingsStore();
const { isDark } = useTheme();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
@ -2217,6 +2220,60 @@ const detailTemporalEditorKind = computed(() => {
return detail ? temporalEditorKindForColumn(detail.colIndex) : undefined;
});
// CodeMirror-based cell detail editors
const detailsEditorContainer = ref<HTMLElement>();
const valueEditorContainer = ref<HTMLElement>();
let detailsDetailEditor: UseCellDetailEditorReturn | null = null;
let valueDetailEditor: UseCellDetailEditorReturn | null = null;
const editorThemeAccessor = () => settingsStore.editorSettings.theme;
const editorAppAppearance = () => (isDark.value ? "dark" : "light") as import("@/lib/appTheme").AppThemeAppearance;
const editorFontSize = () => settingsStore.editorSettings.fontSize;
const editorFontFamily = () => settingsStore.editorSettings.fontFamily;
function getDetailEditor(): UseCellDetailEditorReturn | null {
return activeCellDetailTab.value === "valueEditor" ? valueDetailEditor : detailsDetailEditor;
}
watch(detailsEditorContainer, async (el) => {
if (el && !detailsDetailEditor) {
detailsDetailEditor = useCellDetailEditor({
onChange: (v) => {
detailEditValue.value = v;
},
onEscape: () => cancelDetailEdit(),
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
});
await detailsDetailEditor.create(el, detailEditValue.value, activeCellDetail.value?.type);
} else if (!el && detailsDetailEditor) {
detailsDetailEditor.destroy();
detailsDetailEditor = null;
}
});
watch(valueEditorContainer, async (el) => {
if (el && !valueDetailEditor) {
valueDetailEditor = useCellDetailEditor({
onChange: (v) => {
detailEditValue.value = v;
},
onEscape: () => restoreDetailOriginalValue(),
onBlur: () => commitValueEditorEdit(),
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
});
await valueDetailEditor.create(el, detailEditValue.value, activeCellDetail.value?.type);
} else if (!el && valueDetailEditor) {
valueDetailEditor.destroy();
valueDetailEditor = null;
}
});
function resetDetailEdit() {
isEditingDetail.value = false;
detailEditValue.value = "";
@ -2272,10 +2329,18 @@ function cancelDetailEdit() {
resetDetailEdit();
}
function syncEditorFromDetailEdit() {
const editor = getDetailEditor();
if (editor) {
editor.setValue(detailEditValue.value, activeCellDetail.value?.type);
}
}
function cancelValueEditorEdit() {
const detail = activeCellDetail.value;
if (!detail || !detail.isEditable) return;
detailEditValue.value = cellDetailEditorText(detail.value, detail.type);
syncEditorFromDetailEdit();
isEditingDetail.value = true;
}
@ -2307,6 +2372,7 @@ function restoreDetailOriginalValue() {
}
detailEditValue.value = cellDetailEditorText(restoredValue, detail.type);
syncEditorFromDetailEdit();
isEditingDetail.value = activeCellDetailTab.value === "valueEditor";
detailCell.value = { ...detailCell.value! };
}
@ -2314,6 +2380,7 @@ function restoreDetailOriginalValue() {
function setValueEditorNull() {
setDetailNull();
detailEditValue.value = cellDetailEditorText(null);
syncEditorFromDetailEdit();
isEditingDetail.value = activeCellDetailTab.value === "valueEditor";
}
@ -2321,6 +2388,7 @@ function formatValueEditorJson() {
const detail = activeCellDetail.value;
if (!detail || !canFormatCellDetailJson(detailEditValue.value, detail.type)) return;
detailEditValue.value = formatJsonText(detailEditValue.value) ?? detailEditValue.value;
syncEditorFromDetailEdit();
}
function setDetailNull() {
@ -5110,13 +5178,7 @@ defineExpose({
@cancel="cancelDetailEdit"
@commit="commitDetailEdit"
/>
<textarea
v-else
v-model="detailEditValue"
wrap="off"
class="w-full h-40 overflow-auto rounded border bg-background p-2 font-mono text-xs outline-none resize-y focus:border-primary"
@keydown.escape.stop="cancelDetailEdit"
/>
<div v-else ref="detailsEditorContainer" class="w-full h-40 rounded border overflow-hidden" />
<div class="flex gap-1 mt-1">
<Button size="sm" class="h-6 text-xs" @click="commitDetailEdit">
{{ t("dangerDialog.confirm") }}
@ -5213,13 +5275,10 @@ defineExpose({
@cancel="cancelValueEditorEdit"
@commit="commitValueEditorEdit"
/>
<textarea
<div
v-else
v-model="detailEditValue"
wrap="off"
class="min-h-0 flex-1 w-full overflow-auto rounded border bg-background p-2 font-mono text-xs outline-none resize-none focus:border-primary"
@blur="commitValueEditorEdit"
@keydown.escape.stop="restoreDetailOriginalValue"
ref="valueEditorContainer"
class="min-h-0 flex-1 w-full rounded border overflow-hidden"
/>
</div>
<div class="flex gap-1 mt-2 shrink-0">

View File

@ -0,0 +1,153 @@
import { shallowRef, onBeforeUnmount, type ShallowRef } from "vue";
import { EditorState, Compartment } from "@codemirror/state";
import {
EditorView,
keymap,
drawSelection,
dropCursor,
highlightSpecialChars,
highlightActiveLine,
} from "@codemirror/view";
import { json } from "@codemirror/lang-json";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { bracketMatching } from "@codemirror/language";
import { searchKeymap, search as cmSearch } from "@codemirror/search";
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
import { isJsonColumnType } from "@/lib/cellDetailPresentation";
import type { EditorTheme } from "@/stores/settingsStore";
import type { AppThemeAppearance } from "@/lib/appTheme";
export interface UseCellDetailEditorOptions {
onChange?: (value: string) => void;
onEscape?: () => void;
onBlur?: () => void;
readOnly?: boolean;
editorTheme: () => EditorTheme;
appAppearance: () => AppThemeAppearance;
fontSize: () => number;
fontFamily: () => string;
}
export interface UseCellDetailEditorReturn {
create: (parent: HTMLElement, initialValue: string, columnType?: string) => Promise<void>;
setValue: (value: string, columnType?: string) => void;
getValue: () => string;
destroy: () => void;
view: Readonly<ShallowRef<EditorView | null>>;
}
function looksLikeJsonString(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
function shouldUseJsonMode(columnType?: string, value?: string): boolean {
if (isJsonColumnType(columnType)) return true;
if (value && looksLikeJsonString(value)) return true;
return false;
}
export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCellDetailEditorReturn {
const view = shallowRef<EditorView | null>(null) as ShallowRef<EditorView | null>;
const languageComp = new Compartment();
const themeComp = new Compartment();
const fontThemeComp = new Compartment();
let destroyed = false;
let currentIsJson = false;
async function create(parent: HTMLElement, initialValue: string, columnType?: string): Promise<void> {
if (destroyed) return;
const doc = initialValue ?? "";
currentIsJson = shouldUseJsonMode(columnType, doc);
const theme = await loadEditorTheme(options.editorTheme(), options.appAppearance());
const fontTheme = editorFontTheme(EditorView, options.fontSize(), options.fontFamily(), { scrollable: false });
const state = EditorState.create({
doc,
extensions: [
// Minimal setup without line numbers
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
highlightActiveLine(),
EditorView.theme({
".cm-activeLine": {
backgroundColor: "color-mix(in oklch, var(--foreground) 4%, transparent)",
},
}),
EditorState.allowMultipleSelections.of(true),
bracketMatching(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
// Feature extensions
cmSearch({ top: true }),
EditorView.lineWrapping,
languageComp.of(currentIsJson ? json() : []),
themeComp.of(theme),
fontThemeComp.of(fontTheme),
keymap.of([
{
key: "Escape",
run: () => {
options.onEscape?.();
return true;
},
},
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
options.onChange?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
blur: () => {
options.onBlur?.();
},
}),
EditorState.readOnly.of(!!options.readOnly),
EditorView.editable.of(!options.readOnly),
],
});
view.value = new EditorView({ state, parent });
}
function setValue(value: string, columnType?: string) {
const editor = view.value;
if (!editor || destroyed) return;
const text = value ?? "";
const newIsJson = shouldUseJsonMode(columnType, text);
const effects: ReturnType<typeof Compartment.prototype.reconfigure>[] = [];
if (newIsJson !== currentIsJson) {
effects.push(languageComp.reconfigure(newIsJson ? json() : []));
currentIsJson = newIsJson;
}
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text },
effects,
});
}
function getValue(): string {
return view.value?.state.doc.toString() ?? "";
}
function destroy() {
if (destroyed) return;
destroyed = true;
view.value?.destroy();
view.value = null;
}
onBeforeUnmount(() => {
destroy();
});
return { create, setValue, getValue, destroy, view };
}

View File

@ -29,6 +29,7 @@
"@babel/runtime": "^7.29.2",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",

View File

@ -17,6 +17,9 @@ importers:
'@codemirror/commands':
specifier: ^6.10.3
version: 6.10.3
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-sql':
specifier: ^6.10.0
version: 6.10.0
@ -393,6 +396,9 @@ packages:
'@codemirror/commands@6.10.3':
resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
@ -852,6 +858,9 @@ packages:
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
@ -3926,6 +3935,11 @@ snapshots:
'@codemirror/view': 6.41.1
'@lezer/common': 1.5.2
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.3
'@lezer/json': 1.0.3
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.1
@ -4270,6 +4284,12 @@ snapshots:
dependencies:
'@lezer/common': 1.5.2
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/lr@1.4.10':
dependencies:
'@lezer/common': 1.5.2