feat(web): make custom editor themes portable

Refs #333
This commit is contained in:
tianma-if committed 2026-09-01 22:12:42 +08:00
1 parent cf62dc1b92
commit f47532bebc
7 files changed
+341 -69

No files matched your search

+14 -46
View File
@@ -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");
});
});
+139
View File
@@ -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`;
};
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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: "跟随应用外观",