refactor(desktop): organize lib modules by domain
This commit is contained in:
1 parent
90f0594e93
commit
7e7c4944e9
700 files changed
+1292
-1264
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import type { EditorState, Extension } from "@codemirror/state";
|
||||
import { highlightSelectionMatches, SearchCursor } from "@codemirror/search";
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
|
||||
const MIN_SELECTION_MATCH_LENGTH = 2;
|
||||
const MAX_SELECTION_MATCH_LENGTH = 200;
|
||||
const MAX_SCROLLBAR_MARKS = 1200;
|
||||
|
||||
interface SelectionMatchMarker {
|
||||
from: number;
|
||||
to: number;
|
||||
lineNumber: number;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
function selectedMatchQuery(state: EditorState): string | null {
|
||||
const selection = state.selection.main;
|
||||
if (selection.empty) return null;
|
||||
if (selection.to - selection.from > MAX_SELECTION_MATCH_LENGTH) return null;
|
||||
|
||||
const text = state.sliceDoc(selection.from, selection.to);
|
||||
if (text.length < MIN_SELECTION_MATCH_LENGTH || !text.trim() || /[\r\n]/.test(text)) return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
function buildSelectionMatchMarkers(state: EditorState): SelectionMatchMarker[] {
|
||||
const query = selectedMatchQuery(state);
|
||||
if (!query) return [];
|
||||
|
||||
const selection = state.selection.main;
|
||||
const markers: SelectionMatchMarker[] = [];
|
||||
const seenLines = new Set<number>();
|
||||
const cursor = new SearchCursor(state.doc, query);
|
||||
|
||||
for (let match = cursor.next(); !match.done; match = cursor.next()) {
|
||||
const { from, to } = match.value;
|
||||
if (from === to) continue;
|
||||
|
||||
const line = state.doc.lineAt(from);
|
||||
const selected = from === selection.from && to === selection.to;
|
||||
const lineKey = selected ? -line.number : line.number;
|
||||
if (seenLines.has(lineKey)) continue;
|
||||
seenLines.add(lineKey);
|
||||
|
||||
markers.push({ from, to, lineNumber: line.number, selected });
|
||||
if (markers.length >= MAX_SCROLLBAR_MARKS) break;
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
class SelectionMatchScrollbar {
|
||||
private readonly layer: HTMLElement;
|
||||
private signature = "";
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.layer = document.createElement("div");
|
||||
this.layer.className = "cm-selectionMatchScrollbarLayer";
|
||||
view.dom.appendChild(this.layer);
|
||||
this.render(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.selectionSet || update.geometryChanged) {
|
||||
this.render(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.layer.remove();
|
||||
}
|
||||
|
||||
private render(view: EditorView) {
|
||||
const markers = buildSelectionMatchMarkers(view.state);
|
||||
const lineCount = view.state.doc.lines;
|
||||
const signature = markers.map((marker) => `${marker.lineNumber}:${marker.from}:${marker.to}:${marker.selected}`).join("|");
|
||||
if (signature === this.signature) return;
|
||||
this.signature = signature;
|
||||
this.layer.replaceChildren(
|
||||
...markers.map((marker) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = marker.selected ? "cm-selectionMatchScrollbarMark cm-selectionMatchScrollbarMark-selected" : "cm-selectionMatchScrollbarMark";
|
||||
const ratio = lineCount <= 1 ? 0 : (marker.lineNumber - 1) / (lineCount - 1);
|
||||
el.style.top = `${Math.max(0, Math.min(1, ratio)) * 100}%`;
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const selectionMatchScrollbar = ViewPlugin.fromClass(SelectionMatchScrollbar);
|
||||
|
||||
export function selectionMatchOccurrences(): Extension {
|
||||
return [
|
||||
highlightSelectionMatches({
|
||||
minSelectionLength: MIN_SELECTION_MATCH_LENGTH,
|
||||
maxMatches: 500,
|
||||
}),
|
||||
selectionMatchScrollbar,
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
"--dbx-selection-match-background": "rgb(59 130 246 / 0.07)",
|
||||
"--dbx-selection-match-border": "rgb(59 130 246 / 0.24)",
|
||||
"--dbx-selection-match-main-background": "rgb(59 130 246 / 0.11)",
|
||||
"--dbx-selection-match-main-border": "rgb(59 130 246 / 0.36)",
|
||||
position: "relative",
|
||||
},
|
||||
".dark &": {
|
||||
"--dbx-selection-match-background": "rgb(147 197 253 / 0.12)",
|
||||
"--dbx-selection-match-border": "rgb(147 197 253 / 0.3)",
|
||||
"--dbx-selection-match-main-background": "rgb(147 197 253 / 0.18)",
|
||||
"--dbx-selection-match-main-border": "rgb(147 197 253 / 0.42)",
|
||||
},
|
||||
".cm-selectionMatch": {
|
||||
backgroundColor: "var(--dbx-selection-match-background)",
|
||||
borderRadius: "2px",
|
||||
boxShadow: "inset 0 0 0 1px var(--dbx-selection-match-border)",
|
||||
},
|
||||
".cm-selectionMatch-main": {
|
||||
backgroundColor: "var(--dbx-selection-match-main-background)",
|
||||
boxShadow: "inset 0 0 0 1px var(--dbx-selection-match-main-border)",
|
||||
},
|
||||
".cm-selectionMatchScrollbarLayer": {
|
||||
bottom: "2px",
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
top: "2px",
|
||||
width: "5px",
|
||||
zIndex: "40",
|
||||
},
|
||||
".cm-selectionMatchScrollbarMark": {
|
||||
backgroundColor: "color-mix(in oklab, var(--primary) 48%, transparent)",
|
||||
borderRadius: "999px",
|
||||
height: "2px",
|
||||
position: "absolute",
|
||||
right: "0",
|
||||
transform: "translateY(-50%)",
|
||||
width: "3px",
|
||||
},
|
||||
".cm-selectionMatchScrollbarMark-selected": {
|
||||
backgroundColor: "color-mix(in oklab, var(--primary) 72%, transparent)",
|
||||
height: "3px",
|
||||
width: "4px",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { SQLDialect } from "@codemirror/lang-sql";
|
||||
|
||||
export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver";
|
||||
|
||||
type CodeMirrorSqlLanguageModule = Pick<typeof import("@codemirror/lang-sql"), "MSSQL" | "MySQL" | "PostgreSQL" | "SQLDialect">;
|
||||
|
||||
const DBX_COMMON_SQL_KEYWORDS = [
|
||||
"PIVOT",
|
||||
"UNPIVOT",
|
||||
"EXCLUDE",
|
||||
"REPLACE",
|
||||
"QUALIFY",
|
||||
"ASOF",
|
||||
"POSITIONAL",
|
||||
"ANTI",
|
||||
"SEMI",
|
||||
"SAMPLE",
|
||||
"TABLESAMPLE",
|
||||
"STRUCT",
|
||||
"MAP",
|
||||
"LIST",
|
||||
"ARRAY",
|
||||
"LAMBDA",
|
||||
"UNNEST",
|
||||
"LATERAL",
|
||||
"FILTER",
|
||||
"RECURSIVE",
|
||||
"SUMMARIZE",
|
||||
"PRAGMA",
|
||||
"READ_CSV",
|
||||
"READ_PARQUET",
|
||||
"READ_JSON",
|
||||
"DESCRIBE",
|
||||
"SHOW",
|
||||
"COPY",
|
||||
"EXPORT",
|
||||
"IMPORT",
|
||||
].join(" ");
|
||||
|
||||
const POSTGRES_PLPGSQL_KEYWORDS = "PERFORM";
|
||||
const POSTGRES_PLPGSQL_TYPES = "RECORD JSON JSONB";
|
||||
const POSTGRES_PLPGSQL_BUILTIN = "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV";
|
||||
const POSTGRES_IDENTIFIER_LIKE_KEYWORDS = new Set("COMMENT COUNT DATA DAY HOUR ID KEY LEVEL MINUTE MONTH NAME OWNER PASSWORD POSITION ROLE SECOND TYPE USER VALUE YEAR".split(" "));
|
||||
|
||||
// SQL Server table-valued parameters require READONLY in procedure/function declarations.
|
||||
const SQLSERVER_KEYWORDS = "readonly";
|
||||
|
||||
export function postgresKeywordSyntaxTerms(keywords: string): string {
|
||||
return keywords
|
||||
.split(/\s+/)
|
||||
.filter((keyword) => keyword && !POSTGRES_IDENTIFIER_LIKE_KEYWORDS.has(keyword.toUpperCase()))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModule, dialectName: CodeMirrorSqlDialectName = "mysql"): SQLDialect {
|
||||
const baseDialect = dialectName === "postgres" ? langSql.PostgreSQL : dialectName === "sqlserver" ? langSql.MSSQL : langSql.MySQL;
|
||||
const isPostgres = dialectName === "postgres";
|
||||
const isSqlServer = dialectName === "sqlserver";
|
||||
const baseKeywords = isPostgres ? postgresKeywordSyntaxTerms(baseDialect.spec.keywords || "") : baseDialect.spec.keywords || "";
|
||||
|
||||
return langSql.SQLDialect.define({
|
||||
...baseDialect.spec,
|
||||
keywords: [baseKeywords, DBX_COMMON_SQL_KEYWORDS, isPostgres ? POSTGRES_PLPGSQL_KEYWORDS : "", isSqlServer ? SQLSERVER_KEYWORDS : ""].filter(Boolean).join(" "),
|
||||
types: [baseDialect.spec.types || "", isPostgres ? POSTGRES_PLPGSQL_TYPES : ""].filter(Boolean).join(" ") || undefined,
|
||||
builtin: [baseDialect.spec.builtin || "", isPostgres ? POSTGRES_PLPGSQL_BUILTIN : ""].filter(Boolean).join(" ") || undefined,
|
||||
doubleDollarQuotedStrings: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { layer, RectangleMarker, type EditorView } from "@codemirror/view";
|
||||
|
||||
const FALLBACK_EMPTY_SELECTION_WIDTH = 8;
|
||||
const END_OF_LINE_PADDING = 3;
|
||||
const CONTIGUOUS_LINE_GAP = 1.5;
|
||||
const CORNER_COVERAGE_GAP = 1;
|
||||
|
||||
type TrimmedSelectionRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type PositionRect = ReturnType<EditorView["coordsAtPos"]>;
|
||||
|
||||
export function trimmedSelectionHorizontalBounds(options: {
|
||||
from: number;
|
||||
to: number;
|
||||
lineFrom: number;
|
||||
lineTo: number;
|
||||
includesLineBreak: boolean;
|
||||
startLeft: number;
|
||||
startRight: number;
|
||||
endLeft: number;
|
||||
endRight: number;
|
||||
lineStartLeft: number;
|
||||
lineEndRight: number;
|
||||
emptySelectionWidth?: number;
|
||||
}): {
|
||||
left: number;
|
||||
right: number;
|
||||
} {
|
||||
const lineBreakOnlyAtContentEnd = options.from >= options.to && options.includesLineBreak && options.from >= options.lineTo && options.lineTo > options.lineFrom;
|
||||
const left = options.from < options.to ? Math.min(options.startLeft, options.endLeft) : lineBreakOnlyAtContentEnd ? options.lineEndRight : options.lineStartLeft;
|
||||
const emptySelectionWidth = Math.max(1, options.emptySelectionWidth ?? FALLBACK_EMPTY_SELECTION_WIDTH);
|
||||
let right = options.from < options.to ? Math.max(options.startRight, options.endRight) : left + emptySelectionWidth;
|
||||
if (options.includesLineBreak && options.to >= options.lineTo) {
|
||||
right = Math.max(right, options.lineEndRight + END_OF_LINE_PADDING);
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
function visualLineVerticalBounds(view: EditorView, start: NonNullable<PositionRect>, end: NonNullable<PositionRect>): { top: number; bottom: number } {
|
||||
const top = Math.min(start.top, end.top);
|
||||
const bottom = Math.max(start.bottom, end.bottom);
|
||||
const height = Math.max(bottom - top, view.defaultLineHeight);
|
||||
const center = (top + bottom) / 2;
|
||||
return {
|
||||
top: center - height / 2,
|
||||
bottom: center + height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function layerBase(view: EditorView) {
|
||||
const rect = view.scrollDOM.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left - view.scrollDOM.scrollLeft * view.scaleX,
|
||||
top: rect.top - view.scrollDOM.scrollTop * view.scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
function markerForLineRange(view: EditorView, from: number, to: number, lineFrom: number, lineTo: number, includesLineBreak: boolean, base: { left: number; top: number }): TrimmedSelectionRect | null {
|
||||
const start = view.coordsAtPos(from, 1);
|
||||
const end = from < to ? view.coordsAtPos(to, -1) : start;
|
||||
const lineStart = view.coordsAtPos(lineFrom, 1) ?? start;
|
||||
const lineEnd = view.coordsAtPos(lineTo, -1) ?? end;
|
||||
if (!start || !end || !lineStart || !lineEnd) return null;
|
||||
|
||||
const { left, right } = trimmedSelectionHorizontalBounds({
|
||||
from,
|
||||
to,
|
||||
lineFrom,
|
||||
lineTo,
|
||||
includesLineBreak,
|
||||
startLeft: start.left,
|
||||
startRight: start.right,
|
||||
endLeft: end.left,
|
||||
endRight: end.right,
|
||||
lineStartLeft: lineStart.left,
|
||||
lineEndRight: lineEnd.right,
|
||||
emptySelectionWidth: view.defaultCharacterWidth,
|
||||
});
|
||||
|
||||
const { top, bottom } = visualLineVerticalBounds(view, start, end);
|
||||
|
||||
return {
|
||||
left: left - base.left,
|
||||
top: top - base.top,
|
||||
width: Math.max(1, right - left),
|
||||
height: Math.max(1, bottom - top),
|
||||
};
|
||||
}
|
||||
|
||||
function sameVisualLine(a: NonNullable<PositionRect>, b: NonNullable<PositionRect>): boolean {
|
||||
const aMid = (a.top + a.bottom) / 2;
|
||||
const bMid = (b.top + b.bottom) / 2;
|
||||
return Math.abs(aMid - bMid) <= Math.max(2, Math.min(a.bottom - a.top, b.bottom - b.top) / 2);
|
||||
}
|
||||
|
||||
function lastPositionOnVisualLine(view: EditorView, from: number, to: number, start: NonNullable<PositionRect>): number {
|
||||
let low = from + 1;
|
||||
let high = to;
|
||||
let best = from;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const coords = view.coordsAtPos(mid, -1) ?? view.coordsAtPos(mid, 1);
|
||||
if (coords && sameVisualLine(start, coords)) {
|
||||
best = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return best > from ? best : Math.min(to, from + 1);
|
||||
}
|
||||
|
||||
function markerRectsForLineRange(view: EditorView, from: number, to: number, lineFrom: number, lineTo: number, includesLineBreak: boolean, base: { left: number; top: number }): TrimmedSelectionRect[] {
|
||||
const start = view.coordsAtPos(from, 1);
|
||||
const end = from < to ? view.coordsAtPos(to, -1) : start;
|
||||
if (!start || !end) return [];
|
||||
if (from >= to || sameVisualLine(start, end)) {
|
||||
const rect = markerForLineRange(view, from, to, lineFrom, lineTo, includesLineBreak, base);
|
||||
return rect ? [rect] : [];
|
||||
}
|
||||
|
||||
const rects: TrimmedSelectionRect[] = [];
|
||||
let segmentFrom = from;
|
||||
while (segmentFrom < to) {
|
||||
const segmentStart = view.coordsAtPos(segmentFrom, 1);
|
||||
if (!segmentStart) break;
|
||||
const segmentTo = lastPositionOnVisualLine(view, segmentFrom, to, segmentStart);
|
||||
const rect = markerForLineRange(view, segmentFrom, segmentTo, lineFrom, lineTo, includesLineBreak && segmentTo >= to, base);
|
||||
if (rect) rects.push(rect);
|
||||
if (segmentTo <= segmentFrom) break;
|
||||
segmentFrom = segmentTo;
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
function coversX(rect: TrimmedSelectionRect | undefined, x: number): boolean {
|
||||
if (!rect) return false;
|
||||
return rect.left <= x + CORNER_COVERAGE_GAP && rect.left + rect.width >= x - CORNER_COVERAGE_GAP;
|
||||
}
|
||||
|
||||
function markerClass(rects: TrimmedSelectionRect[], index: number): string {
|
||||
const prev = rects[index - 1];
|
||||
const current = rects[index];
|
||||
const next = rects[index + 1];
|
||||
const touchesPrev = !!prev && Math.abs(prev.top + prev.height - current.top) <= CONTIGUOUS_LINE_GAP;
|
||||
const touchesNext = !!next && Math.abs(current.top + current.height - next.top) <= CONTIGUOUS_LINE_GAP;
|
||||
const left = current.left;
|
||||
const right = current.left + current.width;
|
||||
|
||||
const classes = ["cm-trimmedSelection"];
|
||||
if (!touchesPrev || !coversX(prev, left)) classes.push("cm-trimmedSelection-topLeft");
|
||||
if (!touchesPrev || !coversX(prev, right)) classes.push("cm-trimmedSelection-topRight");
|
||||
if (!touchesNext || !coversX(next, left)) classes.push("cm-trimmedSelection-bottomLeft");
|
||||
if (!touchesNext || !coversX(next, right)) classes.push("cm-trimmedSelection-bottomRight");
|
||||
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
export function trimmedSelectionLayer() {
|
||||
return layer({
|
||||
above: false,
|
||||
class: "cm-trimmedSelectionLayer",
|
||||
markers(view) {
|
||||
const markers: InstanceType<typeof RectangleMarker>[] = [];
|
||||
const base = layerBase(view);
|
||||
|
||||
for (const range of view.state.selection.ranges) {
|
||||
if (range.empty) continue;
|
||||
const rects: TrimmedSelectionRect[] = [];
|
||||
|
||||
for (const visible of view.visibleRanges) {
|
||||
let pos = Math.max(range.from, visible.from, view.viewport.from);
|
||||
const endPos = Math.min(range.to, visible.to, view.viewport.to);
|
||||
|
||||
while (pos < endPos) {
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
const from = Math.max(pos, line.from);
|
||||
const to = Math.min(endPos, line.to);
|
||||
const includesLineBreak = endPos > line.to && range.to > line.to;
|
||||
rects.push(...markerRectsForLineRange(view, from, to, line.from, line.to, includesLineBreak, base));
|
||||
|
||||
const next = line.to + 1;
|
||||
if (next <= pos) break;
|
||||
pos = next;
|
||||
}
|
||||
}
|
||||
|
||||
rects.sort((a, b) => a.top - b.top || a.left - b.left);
|
||||
rects.forEach((rect, index) => {
|
||||
markers.push(new RectangleMarker(markerClass(rects, index), rect.left, rect.top, rect.width, rect.height));
|
||||
});
|
||||
}
|
||||
|
||||
return markers;
|
||||
},
|
||||
update(update) {
|
||||
return update.docChanged || update.selectionSet || update.viewportChanged || update.geometryChanged;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { EditorTheme, CustomThemeColors } from "@/stores/settingsStore";
|
||||
import type { AppThemeAppearance } from "@/lib/app/appTheme";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
type CodeMirrorStyleSpec = Parameters<typeof import("@codemirror/view").EditorView.theme>[0];
|
||||
type LucideIconNode = Array<[string, Record<string, string>]>;
|
||||
|
||||
export const EDITOR_FONT_SIZE_CSS_VAR = "--dbx-editor-font-size";
|
||||
export const EDITOR_FONT_FAMILY_CSS_VAR = "--dbx-editor-font-family";
|
||||
const EDITOR_SELECTION_BACKGROUND_CSS_VAR = "--dbx-editor-selection-background";
|
||||
|
||||
const SUPPORTS_COLOR_MIX = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", "color-mix(in oklch, black 50%, white)");
|
||||
const SUPPORTS_OKLCH = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", "oklch(0.62 0.19 255)");
|
||||
|
||||
// ==================== 自定义主题配置 ====================
|
||||
// 在这里修改你喜欢的颜色!
|
||||
|
||||
const customThemeColors = {
|
||||
lineNumber: "#6c7086", // 行号颜色
|
||||
lineNumberActive: "#cdd6f4", // 当前行号颜色
|
||||
selection: "#313244", // 选中文本背景
|
||||
cursor: "#f5e0dc", // 光标颜色
|
||||
|
||||
// 语法高亮颜色
|
||||
keyword: "#cba6f7", // 关键字 (SELECT, FROM, WHERE 等)
|
||||
string: "#a6e3a1", // 字符串
|
||||
number: "#fab387", // 数字
|
||||
comment: "#6c7086", // 注释
|
||||
type: "#89b4fa", // 类型 (INTEGER, TEXT 等)
|
||||
variable: "#f38ba8", // 变量
|
||||
function: "#89dceb", // 函数
|
||||
operator: "#89b4fa", // 运算符
|
||||
punctuation: "#9399b2", // 标点符号
|
||||
property: "#f9e2af", // 属性/字段名
|
||||
tag: "#cba6f7", // XML/HTML 标签
|
||||
attribute: "#fab387", // 属性名
|
||||
className: "#f9e2af", // 类名
|
||||
|
||||
// UI 元素
|
||||
gutterBackground: "#181825", // 侧边栏背景
|
||||
activeLine: "#313244", // 当前行高亮
|
||||
matchingBracket: "#45475a", // 匹配括号背景
|
||||
|
||||
// 特殊
|
||||
builtin: "#89dceb", // 内置函数
|
||||
meta: "#cdd6f4", // 元信息
|
||||
invalid: "#f38ba8", // 无效字符
|
||||
};
|
||||
|
||||
/** 创建自定义 CodeMirror 主题 */
|
||||
function createCustomTheme(EditorView: typeof import("@codemirror/view").EditorView, colors?: CustomThemeColors, isDark: boolean = true): Extension {
|
||||
// 根据系统主题设置默认背景色和前景色
|
||||
const defaultColors = isDark ? { background: "#1e1e2e", foreground: "#cdd6f4" } : { background: "#fafafa", foreground: "#242424" };
|
||||
|
||||
const c = { ...defaultColors, ...customThemeColors, ...(colors || {}) };
|
||||
|
||||
// 映射用户自定义属性名到 CodeMirror 内部属性名
|
||||
if (colors) {
|
||||
if (colors.field) {
|
||||
c.variable = colors.field;
|
||||
c.property = colors.field;
|
||||
}
|
||||
if (colors.table) {
|
||||
// 表名通常被识别为 propertyName,如果单独设置了表名颜色则覆盖
|
||||
c.property = colors.table;
|
||||
}
|
||||
}
|
||||
|
||||
const theme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: c.background,
|
||||
color: c.foreground,
|
||||
[EDITOR_SELECTION_BACKGROUND_CSS_VAR]: c.selection,
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: c.cursor,
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: c.cursor,
|
||||
},
|
||||
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: c.selection,
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: c.activeLine,
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: c.gutterBackground,
|
||||
color: c.lineNumber,
|
||||
borderRight: "1px solid #313244",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: c.activeLine,
|
||||
color: c.lineNumberActive,
|
||||
},
|
||||
".cm-matchingBracket": {
|
||||
backgroundColor: c.matchingBracket,
|
||||
outline: "none",
|
||||
},
|
||||
},
|
||||
{ dark: isDark },
|
||||
);
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: c.keyword },
|
||||
{ tag: tags.controlKeyword, color: c.keyword },
|
||||
{ tag: tags.definitionKeyword, color: c.keyword },
|
||||
{ tag: tags.moduleKeyword, color: c.keyword },
|
||||
{ tag: tags.operatorKeyword, color: c.keyword },
|
||||
{ tag: tags.string, color: c.string },
|
||||
{ tag: tags.special(tags.string), color: c.string },
|
||||
{ tag: tags.number, color: c.number },
|
||||
{ tag: tags.integer, color: c.number },
|
||||
{ tag: tags.float, color: c.number },
|
||||
{ tag: tags.comment, color: c.comment, fontStyle: "italic" },
|
||||
{ tag: tags.lineComment, color: c.comment, fontStyle: "italic" },
|
||||
{ tag: tags.blockComment, color: c.comment, fontStyle: "italic" },
|
||||
{ tag: tags.typeName, color: c.type },
|
||||
{ tag: tags.typeOperator, color: c.type },
|
||||
{ tag: tags.name, color: c.variable }, // ← 添加:普通标识符(字段名、表名等)
|
||||
{ tag: tags.variableName, color: c.variable },
|
||||
{ tag: tags.definition(tags.variableName), color: c.variable },
|
||||
{ tag: tags.function(tags.variableName), color: c.function },
|
||||
{ tag: tags.function(tags.propertyName), color: c.function },
|
||||
{ tag: tags.standard(tags.variableName), color: c.builtin },
|
||||
{ tag: tags.propertyName, color: c.property },
|
||||
{ tag: tags.operator, color: c.operator },
|
||||
{ tag: tags.compareOperator, color: c.operator },
|
||||
{ tag: tags.logicOperator, color: c.operator },
|
||||
{ tag: tags.arithmeticOperator, color: c.operator },
|
||||
{ tag: tags.punctuation, color: c.punctuation },
|
||||
{ tag: tags.paren, color: c.punctuation },
|
||||
{ tag: tags.brace, color: c.punctuation },
|
||||
{ tag: tags.bracket, color: c.punctuation },
|
||||
{ tag: tags.tagName, color: c.tag },
|
||||
{ tag: tags.attributeName, color: c.attribute },
|
||||
{ tag: tags.attributeValue, color: c.string },
|
||||
{ tag: tags.className, color: c.className },
|
||||
{ tag: tags.bool, color: c.keyword },
|
||||
{ tag: tags.null, color: c.keyword },
|
||||
{ tag: tags.meta, color: c.meta },
|
||||
{ tag: tags.invalid, color: c.invalid },
|
||||
{ tag: tags.heading, color: c.keyword, fontWeight: "bold" },
|
||||
{ tag: tags.heading1, color: c.keyword, fontWeight: "bold" },
|
||||
{ tag: tags.heading2, color: c.keyword, fontWeight: "bold" },
|
||||
{ tag: tags.heading3, color: c.keyword, fontWeight: "bold" },
|
||||
{ tag: tags.strong, color: c.foreground, fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, color: c.foreground, fontStyle: "italic" },
|
||||
{ tag: tags.link, color: c.type, textDecoration: "underline" },
|
||||
{ tag: tags.url, color: c.type, textDecoration: "underline" },
|
||||
{ tag: tags.labelName, color: c.property },
|
||||
{ tag: tags.namespace, color: c.className },
|
||||
{ tag: tags.macroName, color: c.function },
|
||||
{ tag: tags.literal, color: c.string },
|
||||
{ tag: tags.special(tags.string), color: c.string },
|
||||
{ tag: tags.regexp, color: c.string },
|
||||
{ tag: tags.escape, color: c.string },
|
||||
{ tag: tags.processingInstruction, color: c.keyword },
|
||||
{ tag: tags.inserted, color: c.string },
|
||||
{ tag: tags.deleted, color: c.invalid },
|
||||
{ tag: tags.changed, color: c.property },
|
||||
{ tag: tags.self, color: c.keyword },
|
||||
{ tag: tags.derefOperator, color: c.operator },
|
||||
{ tag: tags.unit, color: c.type },
|
||||
{ tag: tags.angleBracket, color: c.punctuation },
|
||||
{ tag: tags.annotation, color: c.property },
|
||||
{ tag: tags.modifier, color: c.keyword },
|
||||
{ tag: tags.list, color: c.foreground },
|
||||
{ tag: tags.quote, color: c.string, fontStyle: "italic" },
|
||||
{ tag: tags.monospace, color: c.foreground },
|
||||
{ tag: tags.strikethrough, color: c.invalid, textDecoration: "line-through" },
|
||||
{ tag: tags.contentSeparator, color: c.operator },
|
||||
{ tag: tags.special(tags.name), color: c.builtin },
|
||||
]);
|
||||
|
||||
return [theme, syntaxHighlighting(highlightStyle)];
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
|
||||
const TABLE_ICON: LucideIconNode = [
|
||||
["path", { d: "M12 3v18" }],
|
||||
["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }],
|
||||
["path", { d: "M3 9h18" }],
|
||||
["path", { d: "M3 15h18" }],
|
||||
];
|
||||
|
||||
const COLUMNS_ICON: LucideIconNode = [
|
||||
["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }],
|
||||
["path", { d: "M12 3v18" }],
|
||||
];
|
||||
|
||||
const KEYWORD_ICON: LucideIconNode = [
|
||||
["path", { d: "m16 18 6-6-6-6" }],
|
||||
["path", { d: "m8 6-6 6 6 6" }],
|
||||
];
|
||||
|
||||
const SNIPPET_ICON: LucideIconNode = [
|
||||
["path", { d: "M8 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h1" }],
|
||||
["path", { d: "M16 3h1a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-1" }],
|
||||
];
|
||||
|
||||
const FUNCTION_ICON: LucideIconNode = [
|
||||
["path", { d: "m15 10 5 5-5 5" }],
|
||||
["path", { d: "M4 4v7a4 4 0 0 0 4 4h12" }],
|
||||
];
|
||||
|
||||
const SCHEMA_ICON: LucideIconNode = [["path", { d: "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v11z" }]];
|
||||
|
||||
function encodeSvgIcon(iconNode: LucideIconNode): string {
|
||||
const body = iconNode
|
||||
.map(
|
||||
([tag, attrs]) =>
|
||||
`<${tag} ${Object.entries(attrs)
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join(" ")} />`,
|
||||
)
|
||||
.join("");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
}
|
||||
|
||||
function lucideCompletionIconMask(iconNode: LucideIconNode) {
|
||||
const mask = encodeSvgIcon(iconNode);
|
||||
return {
|
||||
"--dbx-completion-icon-mask": mask,
|
||||
};
|
||||
}
|
||||
|
||||
function colorMixValue(fallback: string, preferred: string): string {
|
||||
return SUPPORTS_COLOR_MIX ? preferred : fallback;
|
||||
}
|
||||
|
||||
function oklchValue(fallback: string, preferred: string): string {
|
||||
return SUPPORTS_OKLCH ? preferred : fallback;
|
||||
}
|
||||
|
||||
export function cellDetailActiveLineColor(): string {
|
||||
return colorMixValue("var(--accent)", "color-mix(in oklch, var(--foreground) 4%, transparent)");
|
||||
}
|
||||
|
||||
/** Load a CodeMirror theme extension by theme name. */
|
||||
export function resolveEditorTheme(theme: EditorTheme, appAppearance: AppThemeAppearance): Exclude<EditorTheme, "app"> {
|
||||
if (theme === "app") return appAppearance === "dark" ? "one-dark" : "vscode-light";
|
||||
return theme;
|
||||
}
|
||||
|
||||
/** Load a CodeMirror theme extension by theme name. */
|
||||
export async function loadEditorTheme(theme: EditorTheme, appAppearance: AppThemeAppearance = "dark", customColors?: CustomThemeColors): Promise<Extension> {
|
||||
const resolvedTheme = resolveEditorTheme(theme, appAppearance);
|
||||
switch (resolvedTheme) {
|
||||
case "one-dark":
|
||||
return (await import("@codemirror/theme-one-dark")).oneDark;
|
||||
case "vscode-dark":
|
||||
return (await import("@uiw/codemirror-theme-vscode")).vscodeDark;
|
||||
case "vscode-light":
|
||||
return (await import("@uiw/codemirror-theme-vscode")).vscodeLight;
|
||||
case "nord":
|
||||
return (await import("@uiw/codemirror-theme-nord")).nord;
|
||||
case "okaidia":
|
||||
return (await import("@uiw/codemirror-theme-okaidia")).okaidia;
|
||||
case "material":
|
||||
return (await import("@uiw/codemirror-theme-material")).materialDark;
|
||||
case "duotone-light":
|
||||
return (await import("@uiw/codemirror-theme-duotone")).duotoneLight;
|
||||
case "duotone-dark":
|
||||
return (await import("@uiw/codemirror-theme-duotone")).duotoneDark;
|
||||
case "xcode":
|
||||
return (await import("@uiw/codemirror-theme-xcode")).xcodeLight;
|
||||
case "custom":
|
||||
return createCustomTheme((await import("@codemirror/view")).EditorView, customColors, appAppearance === "dark");
|
||||
default:
|
||||
return (await import("@codemirror/theme-one-dark")).oneDark;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEditorFontThemeRules(opts?: { fixedHeight?: boolean; scrollable?: boolean }, defaults?: { size?: number; family?: string }): CodeMirrorStyleSpec {
|
||||
return {
|
||||
"&": {
|
||||
...(opts?.fixedHeight ? { height: "100%" } : {}),
|
||||
fontSize: `var(${EDITOR_FONT_SIZE_CSS_VAR}, ${defaults?.size ?? 13}px)`,
|
||||
},
|
||||
...(opts?.scrollable ? { ".cm-scroller": { overflowX: "auto", overflowY: "auto" } } : {}),
|
||||
".cm-content": {
|
||||
fontFamily: `var(${EDITOR_FONT_FAMILY_CSS_VAR}, ${defaults?.family ?? "monospace"})`,
|
||||
lineHeight: "1.6",
|
||||
padding: "0",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 2px !important",
|
||||
},
|
||||
".cm-selectionLayer .cm-selectionBackground": {
|
||||
display: "none",
|
||||
},
|
||||
".cm-cursor": {
|
||||
height: "1.6em !important",
|
||||
transform: "translateY(-0.3em)",
|
||||
},
|
||||
".cm-trimmedSelection": {
|
||||
backgroundColor: `var(${EDITOR_SELECTION_BACKGROUND_CSS_VAR}, rgb(148 163 184 / 38%))`,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-trimmedSelection-topLeft": {
|
||||
borderTopLeftRadius: "3px",
|
||||
},
|
||||
".cm-trimmedSelection-topRight": {
|
||||
borderTopRightRadius: "3px",
|
||||
},
|
||||
".cm-trimmedSelection-bottomLeft": {
|
||||
borderBottomLeftRadius: "3px",
|
||||
},
|
||||
".cm-trimmedSelection-bottomRight": {
|
||||
borderBottomRightRadius: "3px",
|
||||
},
|
||||
".cm-gutters": {
|
||||
borderRight: "0 !important",
|
||||
fontSize: `var(${EDITOR_FONT_SIZE_CSS_VAR}, ${defaults?.size ?? 13}px)`,
|
||||
fontFamily: `var(${EDITOR_FONT_FAMILY_CSS_VAR}, ${defaults?.family ?? "monospace"})`,
|
||||
position: "relative",
|
||||
userSelect: "none",
|
||||
},
|
||||
".cm-gutters:after": {
|
||||
background: "rgba(148, 163, 184, 0.38)",
|
||||
bottom: "0",
|
||||
content: "''",
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
right: "0",
|
||||
top: "0",
|
||||
width: "1px",
|
||||
zIndex: "10",
|
||||
},
|
||||
".cm-lineNumbers .cm-gutterElement": {
|
||||
cursor: "pointer",
|
||||
paddingRight: "16px",
|
||||
userSelect: "none",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a CodeMirror theme extension for font size + font family. */
|
||||
export function editorFontTheme(EditorView: typeof import("@codemirror/view").EditorView, size: number, family: string, opts?: { fixedHeight?: boolean; scrollable?: boolean }): Extension {
|
||||
return EditorView.theme(buildEditorFontThemeRules(opts, { size, family }));
|
||||
}
|
||||
|
||||
export function buildSqlCompletionThemeRules(): CodeMirrorStyleSpec {
|
||||
return {
|
||||
".cm-tooltip.cm-tooltip-autocomplete": {
|
||||
background: "var(--popover)",
|
||||
backgroundClip: "padding-box",
|
||||
border: colorMixValue("1px solid var(--border)", "1px solid color-mix(in oklch, var(--border) 82%, var(--foreground) 18%)"),
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 8px 18px rgb(0 0 0 / 0.14)",
|
||||
color: "var(--popover-foreground)",
|
||||
fontFamily: `var(${EDITOR_FONT_FAMILY_CSS_VAR}, var(--font-mono, monospace))`,
|
||||
maxWidth: "min(760px, calc(100vw - 24px))",
|
||||
minWidth: "min(280px, calc(100vw - 24px))",
|
||||
overflowX: "hidden",
|
||||
overflowY: "hidden",
|
||||
padding: "4px 0",
|
||||
scrollbarColor: colorMixValue("var(--muted-foreground) transparent", "color-mix(in oklch, var(--muted-foreground) 44%, transparent) transparent"),
|
||||
scrollbarWidth: "thin",
|
||||
zIndex: "9999",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete *": {
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
||||
maxHeight: "min(280px, calc(100vh - 32px))",
|
||||
minWidth: "min(280px, calc(100vw - 24px))",
|
||||
maxWidth: "inherit",
|
||||
overflowX: "hidden",
|
||||
overflowY: "auto",
|
||||
padding: "0 4px 0 !important",
|
||||
scrollbarColor: colorMixValue("var(--muted-foreground) transparent", "color-mix(in oklch, var(--muted-foreground) 44%, transparent) transparent"),
|
||||
scrollbarWidth: "thin",
|
||||
width: "max-content",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul > li": {
|
||||
alignItems: "center",
|
||||
borderRadius: "6px",
|
||||
color: "var(--popover-foreground)",
|
||||
display: "flex",
|
||||
fontSize: `clamp(12px, var(${EDITOR_FONT_SIZE_CSS_VAR}, 13px), 14px)`,
|
||||
fontWeight: "520",
|
||||
height: "28px",
|
||||
letterSpacing: "0",
|
||||
lineHeight: "28px",
|
||||
overflow: "hidden",
|
||||
padding: "0 10px !important",
|
||||
textOverflow: "clip",
|
||||
transition: "background-color 90ms ease, color 90ms ease",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
||||
background: `${colorMixValue("var(--accent)", "color-mix(in oklch, var(--primary) 14%, var(--popover))")} !important`,
|
||||
color: "var(--popover-foreground) !important",
|
||||
outline: colorMixValue("1px solid var(--border)", "1px solid color-mix(in oklch, var(--primary) 22%, transparent)"),
|
||||
},
|
||||
".cm-completionIcon": {
|
||||
alignItems: "center",
|
||||
display: "inline-flex",
|
||||
flex: "0 0 15px",
|
||||
height: "15px",
|
||||
justifyContent: "center",
|
||||
marginRight: "0.65em",
|
||||
opacity: "1",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
width: "15px",
|
||||
},
|
||||
".cm-completionIcon:before": {
|
||||
backgroundColor: "currentColor",
|
||||
content: "''",
|
||||
display: "block",
|
||||
height: "14px",
|
||||
position: "absolute",
|
||||
WebkitMaskImage: "var(--dbx-completion-icon-mask)",
|
||||
WebkitMaskPosition: "center",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
WebkitMaskSize: "14px 14px",
|
||||
maskImage: "var(--dbx-completion-icon-mask)",
|
||||
maskPosition: "center",
|
||||
maskRepeat: "no-repeat",
|
||||
maskSize: "14px 14px",
|
||||
width: "14px",
|
||||
},
|
||||
".cm-completionIcon:after": {
|
||||
content: "'none'",
|
||||
display: "none",
|
||||
},
|
||||
".cm-completionIcon-table": {
|
||||
color: colorMixValue("var(--primary)", "color-mix(in oklch, var(--primary) 92%, var(--popover-foreground))"),
|
||||
...lucideCompletionIconMask(TABLE_ICON),
|
||||
},
|
||||
".cm-completionIcon-column": {
|
||||
color: colorMixValue("var(--blue-500, #3b82f6)", "color-mix(in oklch, var(--blue-500, #3b82f6) 92%, var(--popover-foreground))"),
|
||||
...lucideCompletionIconMask(COLUMNS_ICON),
|
||||
},
|
||||
".cm-completionIcon-keyword": {
|
||||
color: colorMixValue("var(--orange-500, #f97316)", "color-mix(in oklch, var(--orange-500, #f97316) 92%, var(--popover-foreground))"),
|
||||
...lucideCompletionIconMask(KEYWORD_ICON),
|
||||
},
|
||||
".cm-completionIcon-snippet": {
|
||||
color: colorMixValue("var(--violet-500, #8b5cf6)", "color-mix(in oklch, var(--violet-500, #8b5cf6) 92%, var(--popover-foreground))"),
|
||||
...lucideCompletionIconMask(SNIPPET_ICON),
|
||||
},
|
||||
".cm-completionIcon-function": {
|
||||
color: colorMixValue("var(--emerald-500, #10b981)", "color-mix(in oklch, var(--emerald-500, #10b981) 92%, var(--popover-foreground))"),
|
||||
...lucideCompletionIconMask(FUNCTION_ICON),
|
||||
},
|
||||
".cm-completionIcon-schema": {
|
||||
color: colorMixValue("var(--amber-500, #f59e0b)", "color-mix(in oklch, var(--amber-500, #f59e0b) 92%, var(--popover-foreground))"),
|
||||
...lucideCompletionIconMask(SCHEMA_ICON),
|
||||
},
|
||||
".cm-completionLabel": {
|
||||
color: "inherit",
|
||||
flex: "0 1 auto",
|
||||
fontFamily: `var(${EDITOR_FONT_FAMILY_CSS_VAR}, var(--font-mono, monospace))`,
|
||||
fontSize: `clamp(12px, var(${EDITOR_FONT_SIZE_CSS_VAR}, 13px), 14px)`,
|
||||
fontWeight: "520",
|
||||
letterSpacing: "0",
|
||||
minWidth: "8ch",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
".cm-completionMatchedText": {
|
||||
color: oklchValue("rgb(29 132 245)", "oklch(0.62 0.19 255)"),
|
||||
fontWeight: "700",
|
||||
textDecoration: "none",
|
||||
},
|
||||
".cm-completionDetail": {
|
||||
color: colorMixValue("var(--muted-foreground)", "color-mix(in oklch, var(--popover-foreground) 68%, var(--popover))"),
|
||||
fontSize: `clamp(11px, calc(var(${EDITOR_FONT_SIZE_CSS_VAR}, 13px) - 1px), 13px)`,
|
||||
fontWeight: "500",
|
||||
fontStyle: "normal",
|
||||
flex: "1 1 auto",
|
||||
marginLeft: "10px",
|
||||
minWidth: "0",
|
||||
opacity: "1",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
".cm-tooltip.cm-completionInfo": {
|
||||
maxWidth: "min(420px, calc(100vw - 24px))",
|
||||
overflowWrap: "anywhere",
|
||||
zIndex: "10000",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function sqlCompletionTheme(EditorView: typeof import("@codemirror/view").EditorView): Extension {
|
||||
return EditorView.theme(buildSqlCompletionThemeRules());
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export const EDITOR_MIN_FONT_SIZE = 10;
|
||||
export const EDITOR_MAX_FONT_SIZE = 24;
|
||||
const WHEEL_FONT_ZOOM_SENSITIVITY = 0.0015;
|
||||
|
||||
export function clampEditorFontSize(value: number): number {
|
||||
const clamped = Math.min(EDITOR_MAX_FONT_SIZE, Math.max(EDITOR_MIN_FONT_SIZE, value));
|
||||
return Number(clamped.toFixed(2));
|
||||
}
|
||||
|
||||
export function fontSizeFromWheelDelta(currentFontSize: number, deltaY: number): number {
|
||||
return clampEditorFontSize(currentFontSize * Math.exp(-deltaY * WHEEL_FONT_ZOOM_SENSITIVITY));
|
||||
}
|
||||
|
||||
export function fontSizeFromGestureScale(startFontSize: number, scale: number): number {
|
||||
return clampEditorFontSize(startFontSize * scale);
|
||||
}
|
||||
|
||||
export function createEditorZoomCommitScheduler(commit: (fontSize: number) => void, delayMs = 160) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingFontSize: number | null = null;
|
||||
|
||||
const clearPending = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
return {
|
||||
schedule(fontSize: number) {
|
||||
pendingFontSize = clampEditorFontSize(fontSize);
|
||||
clearPending();
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const next = pendingFontSize;
|
||||
pendingFontSize = null;
|
||||
if (next !== null) commit(next);
|
||||
}, delayMs);
|
||||
},
|
||||
flush(fontSize?: number) {
|
||||
const next = clampEditorFontSize(fontSize ?? pendingFontSize ?? NaN);
|
||||
clearPending();
|
||||
pendingFontSize = null;
|
||||
if (!Number.isNaN(next)) commit(next);
|
||||
},
|
||||
hasPendingCommit() {
|
||||
return timer !== null;
|
||||
},
|
||||
dispose() {
|
||||
clearPending();
|
||||
pendingFontSize = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { normalizeShortcutSettings, type ShortcutActionId, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
|
||||
|
||||
export interface ShortcutLikeEvent {
|
||||
key: string;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
}
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
if (key === " ") return "Space";
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
|
||||
function shortcutKeyName(key: string): string | null {
|
||||
if (key === " ") return "Space";
|
||||
if (["Control", "Meta", "Shift", "Alt"].includes(key)) return null;
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
return key;
|
||||
}
|
||||
|
||||
export function eventToShortcut(event: ShortcutLikeEvent): string | null {
|
||||
if (event.isComposing) return null;
|
||||
|
||||
const key = shortcutKeyName(event.key);
|
||||
if (!key) return null;
|
||||
|
||||
const hasModifier = !!event.metaKey || !!event.ctrlKey || !!event.altKey || !!event.shiftKey;
|
||||
if (!hasModifier && key.length === 1) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (event.shiftKey) parts.push("Shift");
|
||||
if (event.metaKey || event.ctrlKey) parts.push("Mod");
|
||||
if (event.altKey) parts.push("Alt");
|
||||
parts.push(key);
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string): boolean {
|
||||
if (event.isComposing || !shortcut) return false;
|
||||
const parts = shortcut.split("+");
|
||||
const key = parts[parts.length - 1] ?? "";
|
||||
const modifiers = new Set(parts.slice(0, -1));
|
||||
const usesMod = modifiers.has("Mod");
|
||||
const usesMeta = modifiers.has("Meta");
|
||||
const usesCtrl = modifiers.has("Ctrl");
|
||||
|
||||
if (usesMod) {
|
||||
if (!event.metaKey && !event.ctrlKey) return false;
|
||||
} else {
|
||||
if (!!event.metaKey !== usesMeta) return false;
|
||||
if (!!event.ctrlKey !== usesCtrl) return false;
|
||||
}
|
||||
|
||||
if (!!event.altKey !== modifiers.has("Alt")) return false;
|
||||
if (!!event.shiftKey !== modifiers.has("Shift")) return false;
|
||||
return normalizeKey(event.key) === normalizeKey(key);
|
||||
}
|
||||
|
||||
function actionShortcut(actionId: ShortcutActionId, shortcuts?: Partial<ShortcutSettings>): string {
|
||||
return normalizeShortcutSettings(shortcuts)[actionId];
|
||||
}
|
||||
|
||||
const SWITCH_TO_TAB_ACTIONS: ShortcutActionId[] = ["switchToTab1", "switchToTab2", "switchToTab3", "switchToTab4", "switchToTab5", "switchToTab6", "switchToTab7", "switchToTab8", "switchToTab9"];
|
||||
|
||||
export function isExecuteSqlShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("executeSql", shortcuts));
|
||||
}
|
||||
|
||||
export function isCloseTabShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("closeTab", shortcuts));
|
||||
}
|
||||
|
||||
export function isNewQueryShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("newQuery", shortcuts));
|
||||
}
|
||||
|
||||
export function isOpenSettingsShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("openSettings", shortcuts));
|
||||
}
|
||||
|
||||
export function isFocusSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("focusSearch", shortcuts));
|
||||
}
|
||||
|
||||
export function isRefreshDataShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("refreshData", shortcuts));
|
||||
}
|
||||
|
||||
export function isModRShortcut(event: ShortcutLikeEvent): boolean {
|
||||
return matchesShortcut(event, "Mod+R");
|
||||
}
|
||||
|
||||
export function isZoomInShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
if (matchesShortcut(event, actionShortcut("zoomInUi", shortcuts))) return true;
|
||||
if (event.isComposing || event.altKey) return false;
|
||||
if (!event.metaKey && !event.ctrlKey) return false;
|
||||
return normalizeKey(event.key) === "NumpadAdd" && !event.shiftKey;
|
||||
}
|
||||
|
||||
export function isZoomOutShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
if (matchesShortcut(event, actionShortcut("zoomOutUi", shortcuts))) return true;
|
||||
if (event.isComposing || event.altKey) return false;
|
||||
if (!event.metaKey && !event.ctrlKey) return false;
|
||||
return normalizeKey(event.key) === "NumpadSubtract" && !event.shiftKey;
|
||||
}
|
||||
|
||||
export function isResetZoomShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
if (matchesShortcut(event, actionShortcut("resetUiZoom", shortcuts))) return true;
|
||||
if (event.isComposing || event.altKey || event.shiftKey) return false;
|
||||
if (!event.metaKey && !event.ctrlKey) return false;
|
||||
return normalizeKey(event.key) === "Numpad0";
|
||||
}
|
||||
|
||||
export function isToggleTransposeShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("toggleTranspose", shortcuts));
|
||||
}
|
||||
|
||||
export function isSaveShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("saveSql", shortcuts));
|
||||
}
|
||||
|
||||
export function isAcceptCompletionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("acceptCompletion", shortcuts));
|
||||
}
|
||||
|
||||
export function isObjectSourceSaveShortcutTarget(target: { closest(selector: string): unknown } | null | undefined): boolean {
|
||||
return !!target?.closest("[data-object-source-editor], [data-object-source-preview]");
|
||||
}
|
||||
|
||||
export function isCopyCurrentRowShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("copyCurrentRow", shortcuts));
|
||||
}
|
||||
|
||||
export function isDeleteCurrentRowShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("deleteCurrentRow", shortcuts));
|
||||
}
|
||||
|
||||
export function isCancelSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("cancelSearch", shortcuts));
|
||||
}
|
||||
|
||||
export function isToggleSidebarShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("toggleSidebar", shortcuts));
|
||||
}
|
||||
|
||||
export function isCopySidebarSelectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("copySidebarSelection", shortcuts));
|
||||
}
|
||||
|
||||
export function isPasteSidebarSelectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("pasteSidebarSelection", shortcuts));
|
||||
}
|
||||
|
||||
export function isEditSidebarConnectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("editSidebarConnection", shortcuts));
|
||||
}
|
||||
|
||||
export function isQuickOpenShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("quickOpen", shortcuts));
|
||||
}
|
||||
|
||||
export function isSwitchToPreviousTabShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("switchToPreviousTab", shortcuts));
|
||||
}
|
||||
|
||||
export function isSwitchToNextTabShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("switchToNextTab", shortcuts));
|
||||
}
|
||||
|
||||
export function switchToTabIndexFromShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): number | null {
|
||||
const normalized = normalizeShortcutSettings(shortcuts);
|
||||
const index = SWITCH_TO_TAB_ACTIONS.findIndex((actionId) => matchesShortcut(event, normalized[actionId]));
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
export function isBrowserReloadShortcut(event: ShortcutLikeEvent): boolean {
|
||||
if (event.isComposing || event.altKey) return false;
|
||||
const key = normalizeKey(event.key);
|
||||
if (key === "F5") return true;
|
||||
return key === "r" && (!!event.metaKey || !!event.ctrlKey);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
|
||||
|
||||
export const DBX_TABLE_REFERENCE_MIME = "application/x-dbx-table-reference";
|
||||
export const DBX_TABLE_REFERENCE_DROP_EVENT = "dbx-table-reference-drop";
|
||||
|
||||
export interface QueryEditorTableReferencePayload {
|
||||
kind: "dbx-table-reference";
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columnName?: string;
|
||||
referenceType?: "table" | "column";
|
||||
databaseType?: DatabaseType;
|
||||
}
|
||||
|
||||
export interface QueryEditorTableReferenceDropDetail {
|
||||
payload: QueryEditorTableReferencePayload;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
let activeTableReferencePayload: QueryEditorTableReferencePayload | null = null;
|
||||
|
||||
export function createTableReferencePayload(options: { connectionId?: string; database?: string; schema?: string; tableName?: string; columnName?: string; databaseType?: DatabaseType }): QueryEditorTableReferencePayload | null {
|
||||
if (!options.connectionId || options.database == null || !options.tableName) return null;
|
||||
const payload: QueryEditorTableReferencePayload = {
|
||||
kind: "dbx-table-reference",
|
||||
connectionId: options.connectionId,
|
||||
database: options.database,
|
||||
tableName: options.tableName,
|
||||
};
|
||||
if (options.columnName) {
|
||||
payload.columnName = options.columnName;
|
||||
payload.referenceType = "column";
|
||||
}
|
||||
if (options.schema) payload.schema = options.schema;
|
||||
if (options.databaseType) payload.databaseType = options.databaseType;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function serializeTableReferencePayload(payload: QueryEditorTableReferencePayload): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function parseTableReferencePayload(value: string | undefined | null): QueryEditorTableReferencePayload | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<QueryEditorTableReferencePayload>;
|
||||
if (parsed.kind !== "dbx-table-reference" || typeof parsed.connectionId !== "string" || typeof parsed.database !== "string" || typeof parsed.tableName !== "string" || !parsed.connectionId || !parsed.tableName) {
|
||||
return null;
|
||||
}
|
||||
const columnName = typeof parsed.columnName === "string" && parsed.columnName ? parsed.columnName : undefined;
|
||||
const referenceType = parsed.referenceType === "column" || columnName ? "column" : "table";
|
||||
if (referenceType === "column" && !columnName) return null;
|
||||
const payload: QueryEditorTableReferencePayload = {
|
||||
kind: "dbx-table-reference",
|
||||
connectionId: parsed.connectionId,
|
||||
database: parsed.database,
|
||||
tableName: parsed.tableName,
|
||||
};
|
||||
if (referenceType === "column" && columnName) {
|
||||
payload.columnName = columnName;
|
||||
payload.referenceType = "column";
|
||||
}
|
||||
if (typeof parsed.schema === "string" && parsed.schema) payload.schema = parsed.schema;
|
||||
if (parsed.databaseType) payload.databaseType = parsed.databaseType;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTableReferencePayloadType(types: Iterable<string> | undefined | null): boolean {
|
||||
if (!types) return false;
|
||||
for (const type of types) {
|
||||
if (type === DBX_TABLE_REFERENCE_MIME) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setActiveTableReferencePayload(payload: QueryEditorTableReferencePayload | null) {
|
||||
activeTableReferencePayload = payload;
|
||||
}
|
||||
|
||||
export function activeTableReferencePayloadValue(): QueryEditorTableReferencePayload | null {
|
||||
return activeTableReferencePayload;
|
||||
}
|
||||
|
||||
export function clearActiveTableReferencePayload(payload?: QueryEditorTableReferencePayload | null) {
|
||||
if (!payload || activeTableReferencePayload === payload) {
|
||||
activeTableReferencePayload = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTableReferenceDropEvent(detail: QueryEditorTableReferenceDropDetail) {
|
||||
return new CustomEvent<QueryEditorTableReferenceDropDetail>(DBX_TABLE_REFERENCE_DROP_EVENT, { detail });
|
||||
}
|
||||
|
||||
export function tableReferenceInsertText(payload: QueryEditorTableReferencePayload, fallbackDatabaseType?: DatabaseType): string {
|
||||
const databaseType = payload.databaseType ?? fallbackDatabaseType;
|
||||
if (payload.referenceType === "column" && payload.columnName) {
|
||||
return quoteTableIdentifier(databaseType, payload.columnName);
|
||||
}
|
||||
return qualifiedTableName({
|
||||
databaseType,
|
||||
schema: payload.schema,
|
||||
tableName: payload.tableName,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
export type ShortcutActionId =
|
||||
| "executeSql"
|
||||
| "formatSql"
|
||||
| "saveSql"
|
||||
| "acceptCompletion"
|
||||
| "indentMore"
|
||||
| "indentLess"
|
||||
| "duplicateLine"
|
||||
| "deleteLine"
|
||||
| "moveLineUp"
|
||||
| "moveLineDown"
|
||||
| "copyLineUp"
|
||||
| "copyLineDown"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "selectAll"
|
||||
| "copyCurrentRow"
|
||||
| "deleteCurrentRow"
|
||||
| "newQuery"
|
||||
| "openSettings"
|
||||
| "closeTab"
|
||||
| "focusSearch"
|
||||
| "quickOpen"
|
||||
| "switchToPreviousTab"
|
||||
| "switchToNextTab"
|
||||
| "switchToTab1"
|
||||
| "switchToTab2"
|
||||
| "switchToTab3"
|
||||
| "switchToTab4"
|
||||
| "switchToTab5"
|
||||
| "switchToTab6"
|
||||
| "switchToTab7"
|
||||
| "switchToTab8"
|
||||
| "switchToTab9"
|
||||
| "zoomInUi"
|
||||
| "zoomOutUi"
|
||||
| "resetUiZoom"
|
||||
| "find"
|
||||
| "replace"
|
||||
| "refreshData"
|
||||
| "toggleTranspose"
|
||||
| "cancelSearch"
|
||||
| "toggleSidebar"
|
||||
| "copySidebarSelection"
|
||||
| "pasteSidebarSelection"
|
||||
| "editSidebarConnection";
|
||||
|
||||
export type ShortcutScope = "global" | "editor" | "grid" | "search" | "sidebar";
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
id: ShortcutActionId;
|
||||
labelKey: string;
|
||||
scope: ShortcutScope;
|
||||
defaultShortcut: string;
|
||||
}
|
||||
|
||||
export type ShortcutSettings = Record<ShortcutActionId, string>;
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{
|
||||
id: "executeSql",
|
||||
labelKey: "settings.shortcutExecuteSql",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Mod+Enter",
|
||||
},
|
||||
{
|
||||
id: "formatSql",
|
||||
labelKey: "settings.shortcutFormatSql",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Shift+Mod+F",
|
||||
},
|
||||
{
|
||||
id: "saveSql",
|
||||
labelKey: "settings.shortcutSaveSql",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Mod+S",
|
||||
},
|
||||
{
|
||||
id: "acceptCompletion",
|
||||
labelKey: "settings.shortcutAcceptCompletion",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Tab",
|
||||
},
|
||||
{
|
||||
id: "indentMore",
|
||||
labelKey: "settings.shortcutIndentMore",
|
||||
scope: "editor",
|
||||
defaultShortcut: "",
|
||||
},
|
||||
{
|
||||
id: "indentLess",
|
||||
labelKey: "settings.shortcutIndentLess",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Shift+Tab",
|
||||
},
|
||||
{
|
||||
id: "duplicateLine",
|
||||
labelKey: "settings.shortcutDuplicateLine",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Mod+D",
|
||||
},
|
||||
{
|
||||
id: "deleteLine",
|
||||
labelKey: "settings.shortcutDeleteLine",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Shift+Mod+K",
|
||||
},
|
||||
{
|
||||
id: "moveLineUp",
|
||||
labelKey: "settings.shortcutMoveLineUp",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Alt+ArrowUp",
|
||||
},
|
||||
{
|
||||
id: "moveLineDown",
|
||||
labelKey: "settings.shortcutMoveLineDown",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Alt+ArrowDown",
|
||||
},
|
||||
{
|
||||
id: "copyLineUp",
|
||||
labelKey: "settings.shortcutCopyLineUp",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Shift+Alt+ArrowUp",
|
||||
},
|
||||
{
|
||||
id: "copyLineDown",
|
||||
labelKey: "settings.shortcutCopyLineDown",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Shift+Alt+ArrowDown",
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
labelKey: "settings.shortcutUndo",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Mod+Z",
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
labelKey: "settings.shortcutRedo",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Shift+Mod+Z",
|
||||
},
|
||||
{
|
||||
id: "selectAll",
|
||||
labelKey: "settings.shortcutSelectAll",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Mod+A",
|
||||
},
|
||||
{
|
||||
id: "copyCurrentRow",
|
||||
labelKey: "settings.shortcutCopyCurrentRow",
|
||||
scope: "grid",
|
||||
defaultShortcut: "Mod+D",
|
||||
},
|
||||
{
|
||||
id: "deleteCurrentRow",
|
||||
labelKey: "settings.shortcutDeleteCurrentRow",
|
||||
scope: "grid",
|
||||
defaultShortcut: "Delete",
|
||||
},
|
||||
{
|
||||
id: "newQuery",
|
||||
labelKey: "settings.shortcutNewQuery",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+T",
|
||||
},
|
||||
{
|
||||
id: "openSettings",
|
||||
labelKey: "settings.shortcutOpenSettings",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+,",
|
||||
},
|
||||
{
|
||||
id: "closeTab",
|
||||
labelKey: "settings.shortcutCloseTab",
|
||||
scope: "global",
|
||||
defaultShortcut: "Meta+W",
|
||||
},
|
||||
{
|
||||
id: "focusSearch",
|
||||
labelKey: "settings.shortcutFocusSearch",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+F",
|
||||
},
|
||||
{
|
||||
id: "quickOpen",
|
||||
labelKey: "settings.shortcutQuickOpen",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+P",
|
||||
},
|
||||
{
|
||||
id: "switchToPreviousTab",
|
||||
labelKey: "settings.shortcutSwitchToPreviousTab",
|
||||
scope: "global",
|
||||
defaultShortcut: "Shift+Mod+[",
|
||||
},
|
||||
{
|
||||
id: "switchToNextTab",
|
||||
labelKey: "settings.shortcutSwitchToNextTab",
|
||||
scope: "global",
|
||||
defaultShortcut: "Shift+Mod+]",
|
||||
},
|
||||
{
|
||||
id: "switchToTab1",
|
||||
labelKey: "settings.shortcutSwitchToTab1",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+1",
|
||||
},
|
||||
{
|
||||
id: "switchToTab2",
|
||||
labelKey: "settings.shortcutSwitchToTab2",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+2",
|
||||
},
|
||||
{
|
||||
id: "switchToTab3",
|
||||
labelKey: "settings.shortcutSwitchToTab3",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+3",
|
||||
},
|
||||
{
|
||||
id: "switchToTab4",
|
||||
labelKey: "settings.shortcutSwitchToTab4",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+4",
|
||||
},
|
||||
{
|
||||
id: "switchToTab5",
|
||||
labelKey: "settings.shortcutSwitchToTab5",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+5",
|
||||
},
|
||||
{
|
||||
id: "switchToTab6",
|
||||
labelKey: "settings.shortcutSwitchToTab6",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+6",
|
||||
},
|
||||
{
|
||||
id: "switchToTab7",
|
||||
labelKey: "settings.shortcutSwitchToTab7",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+7",
|
||||
},
|
||||
{
|
||||
id: "switchToTab8",
|
||||
labelKey: "settings.shortcutSwitchToTab8",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+8",
|
||||
},
|
||||
{
|
||||
id: "switchToTab9",
|
||||
labelKey: "settings.shortcutSwitchToTab9",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+9",
|
||||
},
|
||||
{
|
||||
id: "zoomInUi",
|
||||
labelKey: "settings.shortcutZoomInUi",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+=",
|
||||
},
|
||||
{
|
||||
id: "zoomOutUi",
|
||||
labelKey: "settings.shortcutZoomOutUi",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+-",
|
||||
},
|
||||
{
|
||||
id: "resetUiZoom",
|
||||
labelKey: "settings.shortcutResetUiZoom",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+0",
|
||||
},
|
||||
{
|
||||
id: "find",
|
||||
labelKey: "settings.shortcutFind",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Mod+F",
|
||||
},
|
||||
{
|
||||
id: "replace",
|
||||
labelKey: "settings.shortcutReplace",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Mod+R",
|
||||
},
|
||||
{
|
||||
id: "refreshData",
|
||||
labelKey: "settings.shortcutRefreshData",
|
||||
scope: "global",
|
||||
defaultShortcut: "F5",
|
||||
},
|
||||
{
|
||||
id: "toggleTranspose",
|
||||
labelKey: "settings.shortcutToggleTranspose",
|
||||
scope: "grid",
|
||||
defaultShortcut: "Tab",
|
||||
},
|
||||
{
|
||||
id: "cancelSearch",
|
||||
labelKey: "settings.shortcutCancelSearch",
|
||||
scope: "search",
|
||||
defaultShortcut: "Escape",
|
||||
},
|
||||
{
|
||||
id: "toggleSidebar",
|
||||
labelKey: "settings.shortcutToggleSidebar",
|
||||
scope: "global",
|
||||
defaultShortcut: "Mod+B",
|
||||
},
|
||||
{
|
||||
id: "copySidebarSelection",
|
||||
labelKey: "settings.shortcutCopySidebarSelection",
|
||||
scope: "sidebar",
|
||||
defaultShortcut: "Mod+C",
|
||||
},
|
||||
{
|
||||
id: "pasteSidebarSelection",
|
||||
labelKey: "settings.shortcutPasteSidebarSelection",
|
||||
scope: "sidebar",
|
||||
defaultShortcut: "Mod+V",
|
||||
},
|
||||
{
|
||||
id: "editSidebarConnection",
|
||||
labelKey: "settings.shortcutEditSidebarConnection",
|
||||
scope: "sidebar",
|
||||
defaultShortcut: "Mod+E",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SHORTCUT_SETTINGS: ShortcutSettings = Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition.defaultShortcut])) as ShortcutSettings;
|
||||
|
||||
export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>): ShortcutSettings {
|
||||
return Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, typeof settings?.[definition.id] === "string" ? settings[definition.id] : definition.defaultShortcut])) as ShortcutSettings;
|
||||
}
|
||||
|
||||
export function shortcutToCodeMirrorKey(shortcut: string): string {
|
||||
return shortcut
|
||||
.split("+")
|
||||
.map((part) => (part.length === 1 ? part.toLowerCase() : part))
|
||||
.join("-");
|
||||
}
|
||||
|
||||
export function formatShortcut(shortcut: string, platform = globalThis.navigator?.platform || ""): string {
|
||||
const isMac = platform.toLowerCase().includes("mac");
|
||||
return shortcut
|
||||
.split("+")
|
||||
.map((part) => {
|
||||
if (part === "Mod") return isMac ? "Cmd" : "Ctrl";
|
||||
if (part === "Meta") return isMac ? "Cmd" : "Meta";
|
||||
return part;
|
||||
})
|
||||
.join("+");
|
||||
}
|
||||
|
||||
export function findShortcutConflict(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings): ShortcutActionId | null {
|
||||
if (!shortcut) return null;
|
||||
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
|
||||
if (!definition) return null;
|
||||
|
||||
const conflict = SHORTCUT_DEFINITIONS.find((item) => item.id !== actionId && item.scope === definition.scope && shortcuts[item.id] === shortcut);
|
||||
return conflict?.id ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user