Files
edgeever/apps/web/src/components/settings/PreferenceCard.tsx
T

732 lines
33 KiB
TypeScript

import { useFontPreviewStatus } from "@/lib/use-font-preview-status";
import { FONT_CATALOG, FONT_IDS, isRemoteNoteImageFont } from "@edgeever/shared/font-catalog";
import { FontLoadFeedback } from "@/components/FontLoadFeedback";
import { editorFontLoadState, retryEditorCatalogFont } from "@/lib/editor-catalog-fonts";
import { FontLabel } from "@/components/FontLabel";
import { AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Image, Keyboard, Languages, ListTree, MousePointerClick, Palette, Sparkles, SpellCheck, SunMoon, Type } from "lucide-react";
import { useState, useEffect, useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { NoteProsePatch, NoteProsePaletteChoice, ResolvedNoteProse } from "@edgeever/shared";
import { DEFAULT_NOTE_PROSE_CSS, MAX_NOTE_PROSE_CSS_BYTES, NOTE_PROSE_FONT_SIZES, NOTE_PROSE_PALETTE_CHOICES, NOTE_PROSE_PALETTES, noteProseCssDropsDeclarations } from "@edgeever/shared";
import {
EDITOR_SPELLCHECK_CHANGED_EVENT,
readEditorSpellcheckPreference,
writeEditorSpellcheckPreference,
} from "@/lib/editor-spellcheck-preference";
import {
EDITOR_LINK_OPEN_MODE_CHANGED_EVENT,
getStoredEditorLinkOpenMode,
writeEditorLinkOpenMode,
type EditorLinkOpenMode,
} from "@/lib/editor-link-click";
import {
AI_SELECTION_MENU_CHANGED_EVENT,
readAiSelectionMenuPreference,
writeAiSelectionMenuPreference,
} from "@/lib/ai-selection-menu-preference";
import {
AI_SPACE_SHORTCUT_CHANGED_EVENT,
readAiSpaceShortcutPreference,
writeAiSpaceShortcutPreference,
} from "@/lib/ai-space-shortcut-preference";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { SETTINGS_ITEM_ICON_CLASSNAME, SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
changeAppLocalePreference,
getAppLocalePreference,
localeLabels,
supportedLocales,
type AppLocalePreference,
} from "@/i18n";
import {
applyEditorBodyFontPreference,
BUNDLED_FONT_STACKS,
getFontChoicePreviewStack,
readEditorBodyFontPreference,
writeEditorBodyFontPreference,
type EditorBodyFontChoice,
type EditorBodyFontPreference,
} from "@/lib/editor-body-font";
import { applyUiFontPreference, readUiFontPreference, writeUiFontPreference } from "@/lib/ui-font";
import { syncPublishedNoteBodyFont } from "@/lib/published-note-body-font";
import { useShowDescendantNotesPreference, writeShowDescendantNotesPreference } from "@/lib/descendant-notes-preference";
import { PicGoSettingsItem } from "./PicGoSettingsItem";
import { NoteProseCssEditor } from "./NoteProseCssEditor";
import { NoteProseCssPreview } from "./NoteProseCssPreview";
const PreferenceSection = ({ title, children }: { title: string; children: ReactNode }) => (
<section className="grid gap-2">
<h2 className="px-1 text-xs font-normal leading-5 text-slate-500">{title}</h2>
<div className="divide-y divide-slate-100 overflow-hidden rounded-xl border border-slate-200 bg-card">
{children}
</div>
</section>
);
import {
MERMAID_THEME_PREFERENCES,
useAppearanceTheme,
useMermaidTheme,
type ThemePreference,
} from "../ThemeProvider";
const CUSTOM_FONT_SUGGESTIONS = [
{ label: "苹方 (PingFang SC)", family: "PingFang SC" },
{ label: "微软雅黑 (Microsoft YaHei)", family: "Microsoft YaHei" },
{ label: "鸿蒙黑体 (HarmonyOS)", family: "HarmonyOS Sans SC" },
{ label: "冬青黑体 (Hiragino)", family: "Hiragino Sans GB" },
] as const;
const FontChoiceFields = ({
label,
preference,
onChange,
includeCatalog = false,
}: {
includeCatalog?: boolean;
label: string;
preference: EditorBodyFontPreference;
onChange: (preference: EditorBodyFontPreference) => void;
}) => {
const { t } = useTranslation();
const [fontOpen, setFontOpen] = useState(false);
const [fontRetry, setFontRetry] = useState(0);
const bodyStatus = useSyncExternalStore(editorFontLoadState.subscribe, editorFontLoadState.getSnapshot, editorFontLoadState.getSnapshot);
const catalogSelected = includeCatalog && isRemoteNoteImageFont(preference.choice);
const { status: selectedStatus, onPreviewStatus } = useFontPreviewStatus(`${preference.choice}:${fontRetry}`, bodyStatus === "idle" ? "loading" : bodyStatus);
return (
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-80">
<Select
value={preference.choice}
onOpenChange={setFontOpen}
onValueChange={(value) => onChange({ choice: value as EditorBodyFontChoice, customFamily: preference.customFamily })}
>
<SelectTrigger aria-label={label} className="h-9 bg-card">
<SelectValue>{includeCatalog && isRemoteNoteImageFont(preference.choice) ? <FontLabel label={t(FONT_CATALOG[preference.choice].labelKey)} fontId={preference.choice} enabled onStatusChange={onPreviewStatus} status={selectedStatus} retryKey={fontRetry} /> : undefined}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-80">
<SelectItem value="system">{t("settings.editorBodyFonts.system")}</SelectItem>
{!includeCatalog ? <>
<SelectItem
value="wenkai-screen"
style={{ fontFamily: getFontChoicePreviewStack("wenkai-screen") }}
>
<span style={{ fontFamily: getFontChoicePreviewStack("wenkai-screen") }}>
{t("settings.editorBodyFonts.wenkaiScreen")}
</span>
</SelectItem>
<SelectItem
value="source-han-serif"
style={{ fontFamily: getFontChoicePreviewStack("source-han-serif") }}
>
<span style={{ fontFamily: getFontChoicePreviewStack("source-han-serif") }}>
{t("settings.editorBodyFonts.sourceHanSerif")}
</span>
</SelectItem>
<SelectItem
value="neo-zhi-song"
style={{ fontFamily: getFontChoicePreviewStack("neo-zhi-song") }}
>
<span style={{ fontFamily: getFontChoicePreviewStack("neo-zhi-song") }}>
{t("settings.editorBodyFonts.neoZhiSong")}
</span>
</SelectItem>
<SelectItem
value="source-han-sans"
style={{ fontFamily: getFontChoicePreviewStack("source-han-sans") }}
>
<span style={{ fontFamily: getFontChoicePreviewStack("source-han-sans") }}>
{t("settings.editorBodyFonts.sourceHanSans")}
</span>
</SelectItem>
<SelectItem
value="source-serif"
style={{ fontFamily: getFontChoicePreviewStack("source-serif") }}
>
<span style={{ fontFamily: getFontChoicePreviewStack("source-serif") }}>
{t("settings.editorBodyFonts.sourceSerif")}
</span>
</SelectItem>
</> : null}
{includeCatalog && ["wenkai-screen", "neo-zhi-song", "source-serif"].includes(preference.choice) ? (
<SelectItem value={preference.choice} style={{ fontFamily: getFontChoicePreviewStack(preference.choice) }}>
{t(`settings.editorBodyFonts.${preference.choice === "wenkai-screen" ? "wenkaiScreen" : preference.choice === "neo-zhi-song" ? "neoZhiSong" : "sourceSerif"}`)}
</SelectItem>
) : null}
{(includeCatalog ? FONT_IDS : ["wenkai", "zhuque"] as const).map(id => (
<SelectItem key={id} value={id} textValue={t(FONT_CATALOG[id].labelKey)}>
<FontLabel label={t(FONT_CATALOG[id].labelKey)} fontId={includeCatalog ? id : undefined} fontFamily={includeCatalog ? undefined : BUNDLED_FONT_STACKS[id as "wenkai" | "zhuque"]} enabled={fontOpen} visibleOnly />
</SelectItem>
))}
<SelectItem value="custom">{t("settings.editorBodyFonts.custom")}</SelectItem>
</SelectContent>
</Select>
{catalogSelected ? <FontLoadFeedback status={selectedStatus} onRetry={() => { setFontRetry(value => value + 1); retryEditorCatalogFont(); }} /> : null}
{preference.choice === "custom" ? (
<div className="flex flex-col gap-1.5">
<Input
value={preference.customFamily}
aria-label={t("settings.editorBodyFontCustomLabel")}
placeholder={t("settings.editorBodyFontCustomPlaceholder")}
className="h-9"
maxLength={200}
onChange={(event) => onChange({ choice: "custom", customFamily: event.target.value })}
/>
<div className="flex flex-wrap items-center gap-1 text-xs text-slate-500">
<span className="shrink-0 text-slate-400">{t("settings.editorBodyFontSuggestions")}:</span>
{CUSTOM_FONT_SUGGESTIONS.map((item) => (
<button
key={item.family}
type="button"
className="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => onChange({ choice: "custom", customFamily: item.family })}
>
{item.label}
</button>
))}
</div>
</div>
) : null}
</div>
);
};
const NoteProsePaletteSwatch = ({ paletteId }: { paletteId: NoteProsePaletteChoice }) => (
<span
aria-hidden
className={`h-3 w-6 shrink-0 rounded-[2px] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.14)] ${paletteId === "native" ? "bg-foreground" : ""}`}
style={paletteId === "native" ? undefined : { backgroundColor: NOTE_PROSE_PALETTES[paletteId].accent }}
/>
);
const NOTE_PROSE_LINE_HEIGHT_OPTIONS = [
{ value: "1.5", labelKey: "settings.editorBodyLineHeights.compact" },
{ value: "1.65", labelKey: "settings.editorBodyLineHeights.standard" },
{ value: "2", labelKey: "settings.editorBodyLineHeights.relaxed" },
] as const;
interface PreferenceCardProps {
imageCompressionEnabled: boolean;
onImageCompressionChange: (enabled: boolean) => void;
noteProse: ResolvedNoteProse;
onNoteProseChange: (patch: NoteProsePatch) => void;
}
export const PreferenceCard = ({
imageCompressionEnabled,
onImageCompressionChange,
noteProse,
onNoteProseChange,
}: PreferenceCardProps) => {
const { t } = useTranslation();
const showDescendantNotes = useShowDescendantNotesPreference();
const { preference: appearancePreference, resolvedTheme, setPreference: setAppearancePreference } = useAppearanceTheme();
const { mermaidThemePreference, setMermaidTheme } = useMermaidTheme();
const [cssDialogOpen, setCssDialogOpen] = useState(false);
const [cssDraft, setCssDraft] = useState(noteProse.customCss);
const [cssPreviewTheme, setCssPreviewTheme] = useState<"light" | "dark">("light");
const [activeLocalePreference, setActiveLocalePreference] = useState<AppLocalePreference>(() => getAppLocalePreference());
const [linkOpenMode, setLinkOpenMode] = useState<EditorLinkOpenMode>(() => getStoredEditorLinkOpenMode());
const [aiSelectionMenuEnabled, setAiSelectionMenuEnabled] = useState(readAiSelectionMenuPreference);
const [aiSpaceShortcutEnabled, setAiSpaceShortcutEnabled] = useState(readAiSpaceShortcutPreference);
const [spellcheckEnabled, setSpellcheckEnabled] = useState(readEditorSpellcheckPreference);
const [editorBodyFont, setEditorBodyFont] = useState(readEditorBodyFontPreference);
const [uiFont, setUiFont] = useState(readUiFontPreference);
useEffect(() => {
const syncPreference = () => setAiSpaceShortcutEnabled(readAiSpaceShortcutPreference());
const onPreferenceChanged = (event: Event) => {
const detail = (event as CustomEvent<boolean>).detail;
if (typeof detail === "boolean") {
setAiSpaceShortcutEnabled(detail);
return;
}
syncPreference();
};
window.addEventListener(AI_SPACE_SHORTCUT_CHANGED_EVENT, onPreferenceChanged);
window.addEventListener("storage", syncPreference);
return () => {
window.removeEventListener(AI_SPACE_SHORTCUT_CHANGED_EVENT, onPreferenceChanged);
window.removeEventListener("storage", syncPreference);
};
}, []);
useEffect(() => {
const syncPreference = () => setSpellcheckEnabled(readEditorSpellcheckPreference());
const onPreferenceChanged = (event: Event) => {
const detail = (event as CustomEvent<boolean>).detail;
if (typeof detail === "boolean") {
setSpellcheckEnabled(detail);
return;
}
syncPreference();
};
window.addEventListener(EDITOR_SPELLCHECK_CHANGED_EVENT, onPreferenceChanged);
window.addEventListener("storage", syncPreference);
return () => {
window.removeEventListener(EDITOR_SPELLCHECK_CHANGED_EVENT, onPreferenceChanged);
window.removeEventListener("storage", syncPreference);
};
}, []);
useEffect(() => {
const syncPreference = () => setAiSelectionMenuEnabled(readAiSelectionMenuPreference());
const onPreferenceChanged = (event: Event) => {
const detail = (event as CustomEvent<boolean>).detail;
if (typeof detail === "boolean") {
setAiSelectionMenuEnabled(detail);
return;
}
syncPreference();
};
window.addEventListener(AI_SELECTION_MENU_CHANGED_EVENT, onPreferenceChanged);
window.addEventListener("storage", syncPreference);
return () => {
window.removeEventListener(AI_SELECTION_MENU_CHANGED_EVENT, onPreferenceChanged);
window.removeEventListener("storage", syncPreference);
};
}, []);
useEffect(() => {
const syncMode = () => setLinkOpenMode(getStoredEditorLinkOpenMode());
const onPreferenceChanged = (event: Event) => {
const detail = (event as CustomEvent<EditorLinkOpenMode>).detail;
if (detail === "click" || detail === "modifier") {
setLinkOpenMode(detail);
return;
}
syncMode();
};
window.addEventListener(EDITOR_LINK_OPEN_MODE_CHANGED_EVENT, onPreferenceChanged);
window.addEventListener("storage", syncMode);
return () => {
window.removeEventListener(EDITOR_LINK_OPEN_MODE_CHANGED_EVENT, onPreferenceChanged);
window.removeEventListener("storage", syncMode);
};
}, []);
const cssDraftBytes = new TextEncoder().encode(cssDraft).byteLength;
const openCssDialog = () => {
setCssDraft(noteProse.customCss.trim() ? noteProse.customCss : DEFAULT_NOTE_PROSE_CSS);
setCssPreviewTheme("light");
setCssDialogOpen(true);
};
const resetCssDraft = () => {
setCssDraft(DEFAULT_NOTE_PROSE_CSS);
};
const saveCssDraft = () => {
onNoteProseChange({ customCss: cssDraft });
setCssDialogOpen(false);
};
const handleLocalePreferenceChange = (preference: AppLocalePreference) => {
setActiveLocalePreference(preference);
void changeAppLocalePreference(preference);
};
const updateEditorBodyFont = (preference: EditorBodyFontPreference) => {
setEditorBodyFont(preference);
writeEditorBodyFontPreference(preference);
applyEditorBodyFontPreference(preference);
void syncPublishedNoteBodyFont();
};
const updateUiFont = (preference: EditorBodyFontPreference) => {
setUiFont(preference);
writeUiFontPreference(preference);
applyUiFontPreference(preference);
};
return (
<div className="grid gap-6">
<PreferenceSection title={t("settings.groups.interface")}>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Languages className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.languageTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={activeLocalePreference}
onValueChange={(preference) => handleLocalePreferenceChange(preference as AppLocalePreference)}
>
<SelectTrigger aria-label={t("common.language")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t("settings.systemLanguage")}</SelectItem>
{supportedLocales.map((locale) => (
<SelectItem key={locale} value={locale}>
{localeLabels[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<SunMoon className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.themeTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={appearancePreference}
onValueChange={(value) => setAppearancePreference(value as ThemePreference)}
>
<SelectTrigger aria-label={t("settings.themeTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t("settings.themeSystem")}</SelectItem>
<SelectItem value="light">{t("settings.themeLight")}</SelectItem>
<SelectItem value="dark">{t("settings.themeDark")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<AppWindow className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.uiFontTitle")}</div>
</div>
</div>
<FontChoiceFields
label={t("settings.uiFontTitle")}
preference={uiFont}
onChange={updateUiFont}
/>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<ListTree className={SETTINGS_ITEM_ICON_CLASSNAME} />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.showDescendantNotesTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 justify-start sm:w-44 sm:justify-end">
<Switch
checked={showDescendantNotes}
onCheckedChange={writeShowDescendantNotesPreference}
aria-label={t("settings.showDescendantNotesAria")}
/>
</div>
</div>
</PreferenceSection>
<PreferenceSection title={t("settings.groups.reading")}>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<BookOpenText className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyFontTitle")}</div>
</div>
</div>
<FontChoiceFields
label={t("settings.editorBodyFontTitle")}
preference={editorBodyFont}
includeCatalog
onChange={updateEditorBodyFont}
/>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Type className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyFontSizeTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={String(noteProse.fontSize)}
onValueChange={(value) => onNoteProseChange({ fontSize: Number(value) as ResolvedNoteProse["fontSize"] })}
>
<SelectTrigger aria-label={t("settings.editorBodyFontSizeTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_FONT_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>{size}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<AlignVerticalSpaceAround className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyLineHeightTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={String(noteProse.lineHeight)}
onValueChange={(value) => onNoteProseChange({ lineHeight: Number(value) as ResolvedNoteProse["lineHeight"] })}
>
<SelectTrigger aria-label={t("settings.editorBodyLineHeightTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_LINE_HEIGHT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{t(option.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Palette className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyPaletteTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-80">
<Select
value={noteProse.palette}
onValueChange={(value) => onNoteProseChange({ palette: value as NoteProsePaletteChoice })}
>
<SelectTrigger aria-label={t("settings.editorBodyPaletteTitle")} className="h-9 bg-card">
<SelectValue>
<span className="flex items-center gap-2">
<NoteProsePaletteSwatch paletteId={noteProse.palette} />
<span>{t(`settings.editorBodyPalettes.${noteProse.palette}`)}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_PALETTE_CHOICES.map((paletteId) => (
<SelectItem
key={paletteId}
value={paletteId}
className="pr-2.5 [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
>
<span className="flex w-full items-center justify-between gap-3">
<span>{t(`settings.editorBodyPalettes.${paletteId}`)}</span>
<NoteProsePaletteSwatch paletteId={paletteId} />
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="hidden min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 lg:flex">
<div className="flex min-w-0 items-center gap-3">
<Code2 className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyCssTitle")}</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.accountSyncDescription")}</p>
</div>
</div>
<div className="w-full shrink-0 sm:w-auto">
<Button variant="outline" className="h-9 px-3 text-xs" onClick={openCssDialog}>
{t("settings.editorBodyCssEdit")}
</Button>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<ChartNoAxesCombined className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.mermaidThemeTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select value={mermaidThemePreference} onValueChange={(value) => setMermaidTheme(value as typeof mermaidThemePreference)}>
<SelectTrigger aria-label={t("settings.mermaidThemeTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MERMAID_THEME_PREFERENCES.map((theme) => (
<SelectItem key={theme} value={theme}>
{t(`settings.mermaidThemes.${theme}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</PreferenceSection>
<PreferenceSection title={t("settings.groups.editing")}>
<PicGoSettingsItem />
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Image className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.imageCompressionTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 justify-start sm:w-44 sm:justify-end">
<Switch
checked={imageCompressionEnabled}
onCheckedChange={onImageCompressionChange}
aria-label={t("settings.imageCompressionAria")}
/>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<SpellCheck className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.spellcheckTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 justify-start sm:w-44 sm:justify-end">
<Switch
checked={spellcheckEnabled}
onCheckedChange={(enabled) => {
writeEditorSpellcheckPreference(enabled);
setSpellcheckEnabled(enabled);
}}
aria-label={t("settings.spellcheckAria")}
/>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Sparkles className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.aiSelectionMenuTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 justify-start sm:w-44 sm:justify-end">
<Switch
checked={aiSelectionMenuEnabled}
onCheckedChange={(enabled) => {
writeAiSelectionMenuPreference(enabled);
setAiSelectionMenuEnabled(enabled);
}}
aria-label={t("settings.aiSelectionMenuAria")}
/>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Keyboard className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.aiSpaceShortcutTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 justify-start sm:w-44 sm:justify-end">
<Switch
checked={aiSpaceShortcutEnabled}
onCheckedChange={(enabled) => {
writeAiSpaceShortcutPreference(enabled);
setAiSpaceShortcutEnabled(enabled);
}}
aria-label={t("settings.aiSpaceShortcutAria")}
/>
</div>
</div>
{/* Desktop only: mobile editors always open links on a plain tap. */}
<div className="hidden min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 lg:flex">
<div className="flex min-w-0 items-center gap-3">
<MousePointerClick className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.linkOpenModifierTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 justify-start sm:w-44 sm:justify-end">
<Switch
checked={linkOpenMode === "modifier"}
onCheckedChange={(enabled) => {
const next: EditorLinkOpenMode = enabled ? "modifier" : "click";
writeEditorLinkOpenMode(next);
setLinkOpenMode(next);
}}
aria-label={t("settings.linkOpenModifierAria")}
/>
</div>
</div>
</PreferenceSection>
<Dialog open={cssDialogOpen} onOpenChange={setCssDialogOpen}>
<DialogContent className="flex h-[min(44rem,calc(100dvh-2rem))] max-w-2xl flex-col gap-4 overflow-hidden">
<DialogHeader className="shrink-0 pr-8">
<DialogTitle>{t("settings.editorBodyCssTitle")}</DialogTitle>
<DialogDescription className="leading-5">{t("settings.editorBodyCssDescription")}</DialogDescription>
</DialogHeader>
<div className="shrink-0 overflow-hidden rounded-md border border-slate-200">
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-3 py-1.5">
<span className="text-xs text-slate-500">{t("settings.editorBodyCssPreviewLabel")}</span>
<ToggleGroup
type="single"
value={cssPreviewTheme}
onValueChange={(value) => {
if (value === "light" || value === "dark") setCssPreviewTheme(value);
}}
aria-label={t("settings.editorBodyCssPreviewLabel")}
className="rounded-md bg-muted p-0.5"
>
<ToggleGroupItem value="light" size="sm" className="h-7 rounded px-2.5 text-xs data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:shadow-sm">
{t("settings.editorBodyCssPreviewLight")}
</ToggleGroupItem>
<ToggleGroupItem value="dark" size="sm" className="h-7 rounded px-2.5 text-xs data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:shadow-sm">
{t("settings.editorBodyCssPreviewDark")}
</ToggleGroupItem>
</ToggleGroup>
</div>
<NoteProseCssPreview
css={cssDraft}
dark={cssPreviewTheme === "dark"}
fontSize={noteProse.fontSize}
lineHeight={noteProse.lineHeight}
/>
</div>
{noteProseCssDropsDeclarations(cssDraft) ? (
<p className="shrink-0 text-xs text-amber-700 dark:text-amber-400">{t("settings.editorBodyCssDropped")}</p>
) : null}
<div className="relative min-h-0 flex-1">
<div className="absolute inset-0">
<NoteProseCssEditor
value={cssDraft}
dark={resolvedTheme === "dark"}
ariaLabel={t("settings.editorBodyCssTitle")}
placeholder={t("settings.editorBodyCssPlaceholder")}
onChange={setCssDraft}
/>
</div>
</div>
<p className={cssDraftBytes > MAX_NOTE_PROSE_CSS_BYTES ? "shrink-0 text-xs text-rose-600" : "shrink-0 text-xs text-slate-500"}>
{cssDraftBytes} / {MAX_NOTE_PROSE_CSS_BYTES}
</p>
<DialogFooter className="shrink-0 sm:justify-between">
<Button type="button" variant="outline" onClick={resetCssDraft}>
{t("settings.editorBodyCssReset")}
</Button>
<Button type="button" onClick={saveCssDraft} disabled={cssDraftBytes > MAX_NOTE_PROSE_CSS_BYTES}>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};