1 parent
cf62dc1b92
commit
f47532bebc
7 files changed
+341
-69
No files matched your search
@@ -1,4 +1,18 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
DEFAULT_CUSTOM_DARK_COLORS,
|
||||
DEFAULT_CUSTOM_EDITOR_THEME,
|
||||
DEFAULT_CUSTOM_LIGHT_COLORS,
|
||||
type CustomEditorTheme,
|
||||
type ThemeColors,
|
||||
} from "@/lib/custom-editor-theme";
|
||||
|
||||
export {
|
||||
DEFAULT_CUSTOM_DARK_COLORS,
|
||||
DEFAULT_CUSTOM_EDITOR_THEME,
|
||||
DEFAULT_CUSTOM_LIGHT_COLORS,
|
||||
} from "@/lib/custom-editor-theme";
|
||||
export type { CustomEditorTheme, ThemeColors } from "@/lib/custom-editor-theme";
|
||||
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
@@ -84,52 +98,6 @@ export const EDITOR_THEME_NAMES = [
|
||||
] as const;
|
||||
export type EditorThemeName = string;
|
||||
|
||||
export interface ThemeColors {
|
||||
background: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
heading: string;
|
||||
accent: string;
|
||||
soft: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
export interface CustomEditorTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
light: ThemeColors;
|
||||
dark: ThemeColors;
|
||||
customCss?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CUSTOM_LIGHT_COLORS: ThemeColors = {
|
||||
background: "#fffdf7",
|
||||
text: "#292524",
|
||||
muted: "#57534e",
|
||||
heading: "#1c1917",
|
||||
accent: "#0f766e",
|
||||
soft: "#f0fdfa",
|
||||
border: "#99f6e4",
|
||||
};
|
||||
|
||||
export const DEFAULT_CUSTOM_DARK_COLORS: ThemeColors = {
|
||||
background: "#1c1917",
|
||||
text: "#fafaf9",
|
||||
muted: "#d6d3d1",
|
||||
heading: "#fafaf9",
|
||||
accent: "#2dd4bf",
|
||||
soft: "#292524",
|
||||
border: "#44403c",
|
||||
};
|
||||
|
||||
export const DEFAULT_CUSTOM_EDITOR_THEME: CustomEditorTheme = {
|
||||
id: "custom-default",
|
||||
name: "My custom theme",
|
||||
light: DEFAULT_CUSTOM_LIGHT_COLORS,
|
||||
dark: DEFAULT_CUSTOM_DARK_COLORS,
|
||||
customCss: "",
|
||||
};
|
||||
|
||||
interface AppearanceThemeContextValue {
|
||||
preference: ThemePreference;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RotateCcw, Sun, Moon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Moon, RotateCcw, Sun, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,13 +12,19 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { sanitizeAndScopeCss } from "@/lib/css-sandbox";
|
||||
import { contrastRatio } from "@/lib/color-contrast";
|
||||
import {
|
||||
CustomEditorThemeFileError,
|
||||
DEFAULT_CUSTOM_LIGHT_COLORS,
|
||||
DEFAULT_CUSTOM_DARK_COLORS,
|
||||
MAX_CUSTOM_EDITOR_THEME_FILE_BYTES,
|
||||
customEditorThemeFileName,
|
||||
getEditorThemeContrastIssues,
|
||||
isValidThemeColors,
|
||||
parseCustomEditorThemeFile,
|
||||
serializeCustomEditorTheme,
|
||||
type CustomEditorTheme,
|
||||
type ThemeColors,
|
||||
} from "../ThemeProvider";
|
||||
} from "@/lib/custom-editor-theme";
|
||||
|
||||
interface CustomEditorThemeDialogProps {
|
||||
open: boolean;
|
||||
@@ -39,12 +45,6 @@ const COLOR_FIELDS = [
|
||||
["border", "settings.customEditorTheme.border"],
|
||||
] as const;
|
||||
|
||||
const hasAccessibleContrast = (colors: ThemeColors) =>
|
||||
contrastRatio(colors.text, colors.background) >= 4.5 &&
|
||||
contrastRatio(colors.muted, colors.soft) >= 4.5 &&
|
||||
contrastRatio(colors.heading, colors.background) >= 4.5 &&
|
||||
contrastRatio(colors.accent, colors.background) >= 3;
|
||||
|
||||
export const CustomEditorThemeDialog = ({
|
||||
open,
|
||||
theme,
|
||||
@@ -53,14 +53,17 @@ export const CustomEditorThemeDialog = ({
|
||||
onDelete,
|
||||
isDefaultTheme = false,
|
||||
}: CustomEditorThemeDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [draft, setDraft] = useState<CustomEditorTheme>(theme);
|
||||
const [activeMode, setActiveMode] = useState<"light" | "dark">("light");
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(theme);
|
||||
setActiveMode("light");
|
||||
setImportError(null);
|
||||
}
|
||||
}, [open, theme]);
|
||||
|
||||
@@ -79,15 +82,48 @@ export const CustomEditorThemeDialog = ({
|
||||
const activeColors = draft[activeMode] || DEFAULT_CUSTOM_LIGHT_COLORS;
|
||||
const defaultColors = activeMode === "light" ? DEFAULT_CUSTOM_LIGHT_COLORS : DEFAULT_CUSTOM_DARK_COLORS;
|
||||
|
||||
const validColors = (colors: ThemeColors) =>
|
||||
COLOR_FIELDS.every(([key]) => /^#[0-9a-f]{6}$/i.test(colors[key]));
|
||||
|
||||
const valid =
|
||||
draft.name.trim().length > 0 &&
|
||||
validColors(draft.light) &&
|
||||
validColors(draft.dark) &&
|
||||
hasAccessibleContrast(draft.light) &&
|
||||
hasAccessibleContrast(draft.dark);
|
||||
isValidThemeColors(draft.light) &&
|
||||
isValidThemeColors(draft.dark);
|
||||
const lightContrastIssues = isValidThemeColors(draft.light) ? getEditorThemeContrastIssues(draft.light) : [];
|
||||
const darkContrastIssues = isValidThemeColors(draft.dark) ? getEditorThemeContrastIssues(draft.dark) : [];
|
||||
const activeContrastIssues = activeMode === "light" ? lightContrastIssues : darkContrastIssues;
|
||||
|
||||
const describeImportError = (error: unknown) => {
|
||||
if (error instanceof CustomEditorThemeFileError) {
|
||||
return t(`settings.customEditorTheme.importErrors.${error.code}`);
|
||||
}
|
||||
return t("settings.customEditorTheme.importErrors.invalidFile");
|
||||
};
|
||||
|
||||
const handleImport = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setImportError(null);
|
||||
try {
|
||||
if (file.size > MAX_CUSTOM_EDITOR_THEME_FILE_BYTES) {
|
||||
throw new CustomEditorThemeFileError("fileTooLarge", "Editor theme file is too large.");
|
||||
}
|
||||
const imported = parseCustomEditorThemeFile(await file.text());
|
||||
setDraft({ id: `custom-${crypto.randomUUID()}`, ...imported });
|
||||
setActiveMode("light");
|
||||
} catch (error) {
|
||||
setImportError(describeImportError(error));
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const url = URL.createObjectURL(new Blob([serializeCustomEditorTheme(draft)], { type: "application/json;charset=utf-8" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = customEditorThemeFileName(draft.name);
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setDraft((current) => ({
|
||||
@@ -168,11 +204,16 @@ export const CustomEditorThemeDialog = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!valid && validColors(draft.light) && validColors(draft.dark) ? (
|
||||
{activeContrastIssues.length > 0 ? (
|
||||
<p className="rounded-md bg-amber-50 px-2.5 py-2 text-[11px] leading-4 text-amber-800">
|
||||
{t("settings.customEditorTheme.contrastWarning")}
|
||||
{t("settings.customEditorTheme.contrastWarning", {
|
||||
mode: t(`settings.customEditorTheme.modes.${activeMode}`),
|
||||
fields: new Intl.ListFormat(i18n.resolvedLanguage ?? i18n.language, { type: "conjunction" })
|
||||
.format(activeContrastIssues.map((field) => t(`settings.customEditorTheme.${field}`))),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{importError ? <p className="rounded-md bg-red-50 px-2.5 py-2 text-[11px] leading-4 text-red-700" role="alert">{importError}</p> : null}
|
||||
|
||||
{/* Custom CSS Textarea */}
|
||||
<label className="grid gap-1 text-xs font-semibold text-slate-700">
|
||||
@@ -218,10 +259,25 @@ export const CustomEditorThemeDialog = ({
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0 mt-3 pt-2 border-t border-slate-100">
|
||||
<div className="flex flex-1 gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2.5 text-xs text-slate-500 hover:text-slate-800" onClick={handleReset} title={t("settings.customEditorTheme.reset")}>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2.5 text-xs text-slate-500 hover:text-slate-800" onClick={handleReset}>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
{t("settings.customEditorTheme.reset")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2.5 text-xs text-slate-500 hover:text-slate-800" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="h-3.5 w-3.5 mr-1" />
|
||||
{t("settings.customEditorTheme.import")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2.5 text-xs text-slate-500 hover:text-slate-800" disabled={!valid} onClick={handleExport}>
|
||||
<Download className="h-3.5 w-3.5 mr-1" />
|
||||
{t("settings.customEditorTheme.export")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={(event) => void handleImport(event.target.files?.[0])}
|
||||
/>
|
||||
{onDelete && !isDefaultTheme && (
|
||||
<Button
|
||||
variant="danger"
|
||||
|
||||
@@ -11,3 +11,14 @@ describe("editor content alignment preference", () => {
|
||||
expect(editorPane).not.toContain("onToggleEditorContentAlignment");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom editor theme portability", () => {
|
||||
test("offers import and export while keeping contrast as a warning", () => {
|
||||
const dialog = readFileSync(new URL("./CustomEditorThemeDialog.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(dialog).toContain('t("settings.customEditorTheme.import")');
|
||||
expect(dialog).toContain('t("settings.customEditorTheme.export")');
|
||||
expect(dialog).toContain("activeContrastIssues.length > 0");
|
||||
expect(dialog).not.toContain("hasAccessibleContrast(draft.light)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
CUSTOM_EDITOR_THEME_FILE_SCHEMA,
|
||||
CUSTOM_EDITOR_THEME_FILE_VERSION,
|
||||
CustomEditorThemeFileError,
|
||||
customEditorThemeFileName,
|
||||
getEditorThemeContrastIssues,
|
||||
parseCustomEditorThemeFile,
|
||||
serializeCustomEditorTheme,
|
||||
} from "./custom-editor-theme.ts";
|
||||
|
||||
const theme = {
|
||||
id: "custom-local-only",
|
||||
name: "Calm / Green",
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
text: "#111111",
|
||||
muted: "#444444",
|
||||
heading: "#000000",
|
||||
accent: "#087f5b",
|
||||
soft: "#f1f5f9",
|
||||
border: "#cbd5e1",
|
||||
},
|
||||
dark: {
|
||||
background: "#111111",
|
||||
text: "#f8fafc",
|
||||
muted: "#cbd5e1",
|
||||
heading: "#ffffff",
|
||||
accent: "#34d399",
|
||||
soft: "#1f2937",
|
||||
border: "#475569",
|
||||
},
|
||||
customCss: "h1 { font-style: italic; }",
|
||||
};
|
||||
|
||||
describe("custom editor theme files", () => {
|
||||
test("round-trips a versioned theme without exporting its local id", () => {
|
||||
const serialized = serializeCustomEditorTheme(theme);
|
||||
const document = JSON.parse(serialized);
|
||||
|
||||
expect(document.schema).toBe(CUSTOM_EDITOR_THEME_FILE_SCHEMA);
|
||||
expect(document.version).toBe(CUSTOM_EDITOR_THEME_FILE_VERSION);
|
||||
expect(document.id).toBeUndefined();
|
||||
expect(parseCustomEditorThemeFile(serialized)).toEqual({
|
||||
name: theme.name,
|
||||
light: theme.light,
|
||||
dark: theme.dark,
|
||||
customCss: theme.customCss,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid colors and unsupported versions", () => {
|
||||
const invalidColor = JSON.parse(serializeCustomEditorTheme(theme));
|
||||
invalidColor.light.text = "red";
|
||||
expect(() => parseCustomEditorThemeFile(JSON.stringify(invalidColor))).toThrow(CustomEditorThemeFileError);
|
||||
|
||||
const unsupported = JSON.parse(serializeCustomEditorTheme(theme));
|
||||
unsupported.version = 2;
|
||||
try {
|
||||
parseCustomEditorThemeFile(JSON.stringify(unsupported));
|
||||
throw new Error("Expected an unsupported version error");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CustomEditorThemeFileError);
|
||||
expect(error.code).toBe("unsupportedVersion");
|
||||
}
|
||||
});
|
||||
|
||||
test("reports low contrast without making the theme structurally invalid", () => {
|
||||
expect(getEditorThemeContrastIssues({ ...theme.light, text: "#fefefe", accent: "#eeeeee" }))
|
||||
.toEqual(["text", "accent"]);
|
||||
});
|
||||
|
||||
test("creates a filesystem-safe descriptive filename", () => {
|
||||
expect(customEditorThemeFileName(theme.name)).toBe("EdgeEver-Calm - Green.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { contrastRatio } from "./color-contrast";
|
||||
|
||||
export interface ThemeColors {
|
||||
background: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
heading: string;
|
||||
accent: string;
|
||||
soft: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
export interface CustomEditorTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
light: ThemeColors;
|
||||
dark: ThemeColors;
|
||||
customCss?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CUSTOM_LIGHT_COLORS: ThemeColors = {
|
||||
background: "#fffdf7",
|
||||
text: "#292524",
|
||||
muted: "#57534e",
|
||||
heading: "#1c1917",
|
||||
accent: "#0f766e",
|
||||
soft: "#f0fdfa",
|
||||
border: "#99f6e4",
|
||||
};
|
||||
|
||||
export const DEFAULT_CUSTOM_DARK_COLORS: ThemeColors = {
|
||||
background: "#1c1917",
|
||||
text: "#fafaf9",
|
||||
muted: "#d6d3d1",
|
||||
heading: "#fafaf9",
|
||||
accent: "#2dd4bf",
|
||||
soft: "#292524",
|
||||
border: "#44403c",
|
||||
};
|
||||
|
||||
export const DEFAULT_CUSTOM_EDITOR_THEME: CustomEditorTheme = {
|
||||
id: "custom-default",
|
||||
name: "My custom theme",
|
||||
light: DEFAULT_CUSTOM_LIGHT_COLORS,
|
||||
dark: DEFAULT_CUSTOM_DARK_COLORS,
|
||||
customCss: "",
|
||||
};
|
||||
|
||||
export const CUSTOM_EDITOR_THEME_FILE_SCHEMA = "edgeever.editor-theme";
|
||||
export const CUSTOM_EDITOR_THEME_FILE_VERSION = 1;
|
||||
export const MAX_CUSTOM_EDITOR_THEME_FILE_BYTES = 64 * 1024;
|
||||
export const MAX_CUSTOM_EDITOR_THEME_CSS_LENGTH = 2000;
|
||||
|
||||
const COLOR_FIELDS = ["background", "text", "muted", "heading", "accent", "soft", "border"] as const;
|
||||
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
type PortableCustomEditorTheme = Omit<CustomEditorTheme, "id">;
|
||||
|
||||
export type EditorThemeContrastIssue = "text" | "muted" | "heading" | "accent";
|
||||
export type CustomEditorThemeFileErrorCode = "invalidFile" | "unsupportedVersion" | "fileTooLarge";
|
||||
|
||||
export class CustomEditorThemeFileError extends Error {
|
||||
readonly code: CustomEditorThemeFileErrorCode;
|
||||
|
||||
constructor(code: CustomEditorThemeFileErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "CustomEditorThemeFileError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export const isValidThemeColors = (value: unknown): value is ThemeColors =>
|
||||
isRecord(value) && COLOR_FIELDS.every((field) => typeof value[field] === "string" && HEX_COLOR_PATTERN.test(value[field]));
|
||||
|
||||
export const getEditorThemeContrastIssues = (colors: ThemeColors): EditorThemeContrastIssue[] => {
|
||||
const issues: EditorThemeContrastIssue[] = [];
|
||||
if (contrastRatio(colors.text, colors.background) < 4.5) issues.push("text");
|
||||
if (contrastRatio(colors.muted, colors.soft) < 4.5) issues.push("muted");
|
||||
if (contrastRatio(colors.heading, colors.background) < 4.5) issues.push("heading");
|
||||
if (contrastRatio(colors.accent, colors.background) < 3) issues.push("accent");
|
||||
return issues;
|
||||
};
|
||||
|
||||
export const serializeCustomEditorTheme = (theme: CustomEditorTheme) => JSON.stringify({
|
||||
schema: CUSTOM_EDITOR_THEME_FILE_SCHEMA,
|
||||
version: CUSTOM_EDITOR_THEME_FILE_VERSION,
|
||||
name: theme.name,
|
||||
light: theme.light,
|
||||
dark: theme.dark,
|
||||
customCss: theme.customCss ?? "",
|
||||
}, null, 2);
|
||||
|
||||
export const parseCustomEditorThemeFile = (source: string): PortableCustomEditorTheme => {
|
||||
if (new TextEncoder().encode(source).byteLength > MAX_CUSTOM_EDITOR_THEME_FILE_BYTES) {
|
||||
throw new CustomEditorThemeFileError("fileTooLarge", "Editor theme file is too large.");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(source);
|
||||
} catch {
|
||||
throw new CustomEditorThemeFileError("invalidFile", "Editor theme file must contain valid JSON.");
|
||||
}
|
||||
if (!isRecord(parsed) || parsed.schema !== CUSTOM_EDITOR_THEME_FILE_SCHEMA) {
|
||||
throw new CustomEditorThemeFileError("invalidFile", "Editor theme file has an invalid schema.");
|
||||
}
|
||||
if (parsed.version !== CUSTOM_EDITOR_THEME_FILE_VERSION) {
|
||||
throw new CustomEditorThemeFileError("unsupportedVersion", "Editor theme file version is not supported.");
|
||||
}
|
||||
if (typeof parsed.name !== "string" || parsed.name.trim().length === 0 || parsed.name.trim().length > 32) {
|
||||
throw new CustomEditorThemeFileError("invalidFile", "Editor theme name must contain 1 to 32 characters.");
|
||||
}
|
||||
if (!isValidThemeColors(parsed.light) || !isValidThemeColors(parsed.dark)) {
|
||||
throw new CustomEditorThemeFileError("invalidFile", "Editor theme colors must use six-digit hex values.");
|
||||
}
|
||||
if (parsed.customCss !== undefined && (typeof parsed.customCss !== "string" || parsed.customCss.length > MAX_CUSTOM_EDITOR_THEME_CSS_LENGTH)) {
|
||||
throw new CustomEditorThemeFileError("invalidFile", "Editor theme CSS is invalid or too long.");
|
||||
}
|
||||
|
||||
return {
|
||||
name: parsed.name.trim(),
|
||||
light: parsed.light,
|
||||
dark: parsed.dark,
|
||||
customCss: parsed.customCss ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
export const customEditorThemeFileName = (name: string) => {
|
||||
const safeName = name
|
||||
.trim()
|
||||
.replace(/[\u0000-\u001f<>:"/\\|?*]+/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[. ]+$/g, "")
|
||||
.slice(0, 80) || "custom-theme";
|
||||
return `EdgeEver-${safeName}.json`;
|
||||
};
|
||||
@@ -269,6 +269,8 @@ export const enUS = {
|
||||
title: "Custom editor theme",
|
||||
description: "Adjust editor colors. This setting is saved in the current browser.",
|
||||
edit: "Edit colors",
|
||||
import: "Import",
|
||||
export: "Export",
|
||||
reset: "Restore defaults",
|
||||
name: "Theme name",
|
||||
background: "Background",
|
||||
@@ -282,7 +284,16 @@ export const enUS = {
|
||||
previewBody: "This is a body text preview to help you evaluate the color combination.",
|
||||
previewMuted: "Muted supporting text should remain clear on the soft background.",
|
||||
previewAccent: "Accent text",
|
||||
contrastWarning: "Body, muted, heading, or accent colors need more contrast before saving.",
|
||||
contrastWarning: "{{fields}} have low contrast in {{mode}}. You can still save this theme.",
|
||||
modes: {
|
||||
light: "light mode",
|
||||
dark: "dark mode",
|
||||
},
|
||||
importErrors: {
|
||||
invalidFile: "Import failed: this is not a valid EdgeEver editor theme file.",
|
||||
unsupportedVersion: "Import failed: this editor theme file version is not supported.",
|
||||
fileTooLarge: "Import failed: the editor theme file exceeds 64 KiB.",
|
||||
},
|
||||
},
|
||||
mermaidThemes: {
|
||||
auto: "Follow app appearance",
|
||||
|
||||
@@ -269,6 +269,8 @@ export const zhCN = {
|
||||
title: "自定义编辑器主题",
|
||||
description: "调整正文编辑区的颜色,设置仅保存在当前浏览器中。",
|
||||
edit: "编辑配色",
|
||||
import: "导入",
|
||||
export: "导出",
|
||||
reset: "恢复默认",
|
||||
name: "主题名称",
|
||||
background: "背景色",
|
||||
@@ -282,7 +284,16 @@ export const zhCN = {
|
||||
previewBody: "这是一段正文预览,用来感受背景、文字和边框的组合效果。",
|
||||
previewMuted: "辅助说明文字需要在柔和背景上保持清晰。",
|
||||
previewAccent: "强调文字",
|
||||
contrastWarning: "正文、辅助文字、标题或强调色的对比度不足,请调整后保存。",
|
||||
contrastWarning: "{{mode}}的{{fields}}对比度较低;你仍可保存此主题。",
|
||||
modes: {
|
||||
light: "浅色模式",
|
||||
dark: "深色模式",
|
||||
},
|
||||
importErrors: {
|
||||
invalidFile: "无法导入:文件不是有效的 EdgeEver 编辑器主题。",
|
||||
unsupportedVersion: "无法导入:该主题文件版本暂不受支持。",
|
||||
fileTooLarge: "无法导入:主题文件超过 64 KiB。",
|
||||
},
|
||||
},
|
||||
mermaidThemes: {
|
||||
auto: "跟随应用外观",
|
||||
|
||||
Reference in new issue
Block a user