feat: use codemirror for cell detail editor
This commit is contained in:
parent
2038501b53
commit
366756333a
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue