Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { defaultMarkdownShortcuts } from "@markra/editor";
import { it as registerTest } from "vitest";
import desktopPackage from "../package.json";
import { defaultAiQuickActionPrompts } from "./lib/ai-actions";
import { defaultExportSettings } from "./lib/settings/app-settings";
import {
dispatchAiEditorPreviewAction,
installAppTestHarness,
Expand Down Expand Up @@ -8696,6 +8697,10 @@ describe("Markra workspace", () => {
});

it("exports the current markdown document as standalone HTML from the native menu", async () => {
mockedGetStoredExportSettings.mockResolvedValue({
...defaultExportSettings,
fontFamily: "Example Serif"
});
mockOpenMarkdownFile({
content: "# Exportable\n\nRendered from markdown.",
name: "exportable.md",
Expand Down Expand Up @@ -8731,6 +8736,7 @@ describe("Markra workspace", () => {
const exportedHtml = mockedSaveNativeHtmlFile.mock.calls.at(-1)?.[0].contents ?? "";
expect(exportedHtml).toContain("<p>Rendered from markdown.</p>");
expect(exportedHtml).toContain("<title>exportable.md</title>");
expect(exportedHtml).toContain('font-family: "Example Serif", ui-serif');
});

it("exports the current markdown document as PDF from the native menu", async () => {
Expand All @@ -8740,6 +8746,7 @@ describe("Markra workspace", () => {
path: "/mock-files/printable.pdf"
});
mockedGetStoredExportSettings.mockResolvedValue({
fontFamily: null,
pandocArgs: "",
pandocPath: "",
pdfAuthor: "Ada & Co",
Expand Down Expand Up @@ -8802,6 +8809,7 @@ describe("Markra workspace", () => {
path: "/mock-files/portable.docx"
});
mockedGetStoredExportSettings.mockResolvedValue({
fontFamily: null,
pandocArgs: "--toc",
pandocPath: "/usr/local/bin/pandoc",
pdfAuthor: "",
Expand Down Expand Up @@ -8895,6 +8903,7 @@ describe("Markra workspace", () => {
it("opens settings directly to the Pandoc path target", async () => {
window.history.pushState({}, "", "/?settings=1&settingsTarget=exportPandocPath");
mockedGetStoredExportSettings.mockResolvedValue({
fontFamily: null,
pandocArgs: "",
pandocPath: "",
pdfAuthor: "",
Expand All @@ -8918,6 +8927,7 @@ describe("Markra workspace", () => {
window.history.pushState({}, "", "/?settings=1&settingsTarget=exportPandocPath");
mockedDetectNativePandocPath.mockResolvedValue("/opt/homebrew/bin/pandoc");
mockedGetStoredExportSettings.mockResolvedValue({
fontFamily: null,
pandocArgs: "",
pandocPath: "",
pdfAuthor: "",
Expand Down Expand Up @@ -8948,6 +8958,7 @@ describe("Markra workspace", () => {
it("saves the PDF export margin from the settings export page", async () => {
window.history.pushState({}, "", "/?settings");
mockedGetStoredExportSettings.mockResolvedValue({
fontFamily: null,
pandocArgs: "",
pandocPath: "",
pdfAuthor: "",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3670,6 +3670,7 @@ function WorkspaceApp() {
const pdfSettings = exported.kind === "pdf" ? exportSettings.settings : null;
const contents = buildMarkdownHtmlDocument({
bodyHtml: exported.bodyHtml,
fontFamily: exportSettings.settings.fontFamily,
language: appLanguage.language,
pdfAuthor: pdfSettings?.pdfAuthor,
pdfFooter: pdfSettings?.pdfFooter,
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/SettingsWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export function SettingsWindow() {
focusTarget={settingsFocusTarget}
pandocEnabled={appFeatures.pandoc}
settings={exportSettings}
systemFontFamilies={systemFontFamilies}
translate={translate}
onDetectPandocPath={handleDetectPandocPath}
onFocusTargetHandled={clearSettingsFocusTarget}
Expand Down
217 changes: 11 additions & 206 deletions packages/app/src/components/settings/EditorSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,13 @@ import {
Moon,
RotateCcw,
Save,
Search,
type LucideIcon
} from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent
} from "react";
import {
Expand Down Expand Up @@ -47,12 +44,9 @@ import {
editorCustomContentWidthMin,
normalizeEditorContentWidthPx
} from "../../lib/editor-width";
import {
editorFontFamilyCssValue,
normalizeSystemFontFamilyName
} from "../../lib/editor-font";
import type { AppSystemFontFamily } from "../../runtime";
import { SortableTitlebarAction } from "../SortableTitlebarAction";
import { FontFamilySelect } from "./FontFamilySelect";
import {
SettingsButton,
SettingsNumberInput,
Expand Down Expand Up @@ -131,13 +125,6 @@ const sidebarLayoutOptions: Array<{
const titlebarActionVisibleClassName =
"aria-pressed:border-transparent aria-pressed:bg-(--bg-active) aria-pressed:text-(--text-heading) aria-pressed:opacity-100 aria-pressed:hover:bg-(--bg-active)";
const titlebarActionHiddenClassName = "text-(--text-secondary) opacity-55 hover:opacity-100";
const editorFontFamilyListboxId = "settings-editor-font-family-options";

type EditorFontFamilySelectOption = {
fontFamily: string | null;
label: string;
};

function contentWidthRatioValue(preferences: EditorPreferences) {
const contentWidthPx = preferences.contentWidthPx ?? editorContentWidthPixels[preferences.contentWidth];

Expand All @@ -151,186 +138,6 @@ function contentWidthPxFromRatio(value: number) {
return normalizeEditorContentWidthPx(Math.round(editorCustomContentWidthMin + (contentWidthRatioSpanPx * ratio) / 100));
}

function editorFontFamilySelectOptions(
preferences: EditorPreferences,
systemFontFamilies: readonly AppSystemFontFamily[],
themeLabel: string
): EditorFontFamilySelectOption[] {
const fontFamilyOptions = new Map<string, EditorFontFamilySelectOption>();
for (const fontFamily of systemFontFamilies) {
const normalizedFontFamily = normalizeSystemFontFamilyName(fontFamily.family);
if (!normalizedFontFamily) continue;

const normalizedLabel = normalizeSystemFontFamilyName(fontFamily.label) ?? normalizedFontFamily;
if (!fontFamilyOptions.has(normalizedFontFamily)) {
fontFamilyOptions.set(normalizedFontFamily, {
fontFamily: normalizedFontFamily,
label: normalizedLabel
});
}
}
if (fontFamilyOptions.size === 0 && preferences.editorFontFamily.source === "system") {
fontFamilyOptions.set(preferences.editorFontFamily.family, {
fontFamily: preferences.editorFontFamily.family,
label: preferences.editorFontFamily.family
});
}

return [
{ fontFamily: null, label: themeLabel },
...Array.from(fontFamilyOptions.values())
.sort((first, second) => first.label.localeCompare(second.label) || first.fontFamily!.localeCompare(second.fontFamily!))
];
}

function optionMatchesQuery(option: EditorFontFamilySelectOption, query: string) {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) return true;

return option.label.toLocaleLowerCase().includes(normalizedQuery);
}

function selectedFontFamilyOption(options: readonly EditorFontFamilySelectOption[], preferences: EditorPreferences) {
if (preferences.editorFontFamily.source === "theme") return options[0]!;

return options.find((option) => option.fontFamily === preferences.editorFontFamily.family) ?? options[0]!;
}

function editorFontFamilyOptionStyle(option: EditorFontFamilySelectOption): CSSProperties | undefined {
if (option.fontFamily === null) return undefined;

return {
fontFamily: editorFontFamilyCssValue({
family: option.fontFamily,
source: "system"
}) ?? undefined
};
}

function SearchableEditorFontFamilySelect({
label,
onChange,
options,
value
}: {
label: string;
onChange: (option: EditorFontFamilySelectOption) => unknown;
options: EditorFontFamilySelectOption[];
value: EditorFontFamilySelectOption;
}) {
const [activeIndex, setActiveIndex] = useState(0);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const inputValue = open ? query : value.label;
const inputStyle = open ? undefined : editorFontFamilyOptionStyle(value);
const filteredOptions = useMemo(
() => options.filter((option) => optionMatchesQuery(option, query)),
[options, query]
);
const visibleOptions = filteredOptions;
const activeOption = visibleOptions[activeIndex] ?? visibleOptions[0];

useEffect(() => {
setActiveIndex(0);
}, [open, query]);

const selectOption = (option: EditorFontFamilySelectOption) => {
setQuery("");
setOpen(false);
onChange(option);
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
if (visibleOptions.length === 0) return;
setActiveIndex((currentIndex) => Math.min(currentIndex + 1, visibleOptions.length - 1));
return;
}

if (event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
if (visibleOptions.length === 0) return;
setActiveIndex((currentIndex) => Math.max(currentIndex - 1, 0));
return;
}

if (event.key === "Enter" && open && activeOption) {
event.preventDefault();
selectOption(activeOption);
return;
}

if (event.key === "Escape") {
setOpen(false);
setQuery("");
}
};

return (
<div className="relative inline-flex min-w-56 items-center">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2.5 z-10 text-(--text-secondary)"
size={13}
/>
<input
className="h-8 w-56 rounded-md border border-(--border-default) bg-(--bg-primary) py-0 pr-3 pl-8 text-[12px] leading-5 font-[560] text-(--text-heading) transition-colors duration-150 ease-out hover:bg-(--bg-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--accent)"
type="text"
role="combobox"
aria-activedescendant={open && activeOption ? `${editorFontFamilyListboxId}-${activeIndex}` : undefined}
aria-autocomplete="list"
aria-controls={editorFontFamilyListboxId}
aria-expanded={open}
aria-label={label}
autoCapitalize="none"
autoCorrect="off"
placeholder={value.label}
spellCheck={false}
style={inputStyle}
value={inputValue}
onBlur={() => {
setOpen(false);
setQuery("");
}}
onChange={(event) => {
setQuery(event.currentTarget.value);
setOpen(true);
}}
onFocus={() => {
setQuery("");
setOpen(true);
}}
onKeyDown={handleKeyDown}
/>
{open ? (
<div
className="absolute top-full right-0 left-0 z-40 mt-1 max-h-56 overflow-y-auto rounded-md border border-(--border-default) bg-(--bg-primary) py-1 shadow-[0_12px_34px_rgba(0,0,0,0.14)]"
id={editorFontFamilyListboxId}
role="listbox"
>
{visibleOptions.map((option, index) => (
<button
key={option.fontFamily ?? "theme"}
className="flex h-8 w-full cursor-pointer items-center border-0 bg-transparent px-3 text-left text-[12px] leading-5 font-[560] text-(--text-heading) transition-colors duration-150 ease-out hover:bg-(--bg-hover) focus:bg-(--bg-hover) focus:outline-none aria-selected:bg-(--bg-active)"
id={`${editorFontFamilyListboxId}-${index}`}
role="option"
aria-selected={option.label === value.label}
style={editorFontFamilyOptionStyle(option)}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectOption(option)}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>
) : null}
</div>
);
}

function SettingsContentWidthInput({
label,
onUpdatePreferences,
Expand Down Expand Up @@ -655,12 +462,9 @@ export function EditorSettings({
systemFontFamilies?: readonly AppSystemFontFamily[];
translate: SettingsTranslate;
}) {
const editorFontFamilyOptions = editorFontFamilySelectOptions(
preferences,
systemFontFamilies,
translate("settings.editor.fontFamily.theme")
);
const editorFontFamilyValue = selectedFontFamilyOption(editorFontFamilyOptions, preferences);
const editorFontFamily = preferences.editorFontFamily.source === "system"
? preferences.editorFontFamily.family
: null;

return (
<>
Expand All @@ -669,16 +473,17 @@ export function EditorSettings({
title={translate("settings.editor.fontFamily")}
description={translate("settings.editor.fontFamilyDescription")}
action={
<SearchableEditorFontFamilySelect
<FontFamilySelect
defaultLabel={translate("settings.editor.fontFamily.theme")}
family={editorFontFamily}
label={translate("settings.editor.fontFamily")}
value={editorFontFamilyValue}
options={editorFontFamilyOptions}
onChange={(option) =>
systemFontFamilies={systemFontFamilies}
onChange={(family) =>
onUpdatePreferences({
...preferences,
editorFontFamily: option.fontFamily === null
editorFontFamily: family === null
? { family: null, source: "theme" }
: { family: option.fontFamily, source: "system" }
: { family, source: "system" }
})
}
/>
Expand Down
38 changes: 38 additions & 0 deletions packages/app/src/components/settings/ExportSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { defaultExportSettings } from "../../lib/settings/app-settings";
import { translate } from "../../test/settings-components";
import { ExportSettings } from "./ExportSettings";

describe("ExportSettings", () => {
it("selects an installed font for HTML and PDF exports", () => {
const onUpdateSettings = vi.fn();
const settings = {
...defaultExportSettings,
fontFamily: null
};

render(
<ExportSettings
settings={settings}
systemFontFamilies={[
{ family: "Example Sans", label: "Example Sans" },
{ family: "Example Serif", label: "Example Serif" }
]}
translate={translate}
onUpdateSettings={onUpdateSettings}
/>
);

const fontFamilySelect = screen.getByRole("combobox", { name: "Export font" });

expect(fontFamilySelect).toHaveValue("Default");
fireEvent.focus(fontFamilySelect);
fireEvent.change(fontFamilySelect, { target: { value: "ser" } });
fireEvent.click(screen.getByRole("option", { name: "Example Serif" }));

expect(onUpdateSettings).toHaveBeenCalledWith({
...settings,
fontFamily: "Example Serif"
});
});
});
Loading
Loading