Skip to content

Commit

Permalink
feat: Custom font family (#3845)
Browse files Browse the repository at this point in the history
Closes #3817

## Description

This allows to enter any font family as well as adds safe default
chinese fonts.

Todo
- [x] use combobox
- [x] allow preview on hover
- [x] allow user to type fallback via comma, don't add a fallback to a
custom font
- [x] validate input?
- [x] add fonts
- [x] add global values

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
  • Loading branch information
kof authored Aug 1, 2024
1 parent f446df6 commit 918ca2f
Show file tree
Hide file tree
Showing 14 changed files with 179 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Text,
rawTheme,
} from "@webstudio-is/design-system";
import { UploadIcon } from "@webstudio-is/icons";
import { UpgradeIcon } from "@webstudio-is/icons";
import { useStore } from "@nanostores/react";
import { $userPlanFeatures } from "~/builder/shared/nano-states";
import cmsUpgradeBanner from "./cms-upgrade-banner.svg?url";
Expand Down Expand Up @@ -39,7 +39,7 @@ export const SettingsPanelContainer = ({
any other structured content.
</Text>
<Flex align="center" gap={1}>
<UploadIcon />
<UpgradeIcon />
<Link
color="inherit"
target="_blank"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
useEffect,
} from "react";
import { mergeRefs } from "@react-aria/utils";
import { CopyIcon, RefreshIcon, UploadIcon } from "@webstudio-is/icons";
import { CopyIcon, RefreshIcon, UpgradeIcon } from "@webstudio-is/icons";
import {
Box,
Button,
Expand Down Expand Up @@ -687,7 +687,7 @@ export const VariablePopoverTrigger = forwardRef<
<PanelBanner>
<Text>Resource fetching is part of the CMS functionality.</Text>
<Flex align="center" gap={1}>
<UploadIcon />
<UpgradeIcon />
<Link
color="inherit"
target="_blank"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,122 @@
import { DeprecatedTextField } from "@webstudio-is/design-system";
import {
Combobox,
EnhancedTooltip,
Flex,
NestedInputButton,
} from "@webstudio-is/design-system";
import { FontsManager } from "~/builder/shared/fonts-manager";
import type { ControlProps } from "../types";
import { FloatingPanel } from "~/builder/shared/floating-panel";
import { useState } from "react";
import { forwardRef, useMemo, useState, type ComponentProps } from "react";
import { toValue } from "@webstudio-is/css-engine";
import { matchSorter } from "match-sorter";
import { useAssets } from "~/builder/shared/assets";
import { toItems } from "~/builder/shared/fonts-manager";
import { UploadIcon } from "@webstudio-is/icons";
import { styleConfigByName } from "../../shared/configs";
import { parseCssValue } from "@webstudio-is/css-data";

type Item = { value: string; label?: string };

const matchOrSuggestToCreate = (
search: string,
items: Array<Item>,
itemToString: (item: Item) => string
): Array<Item> => {
const matched = matchSorter(items, search, {
keys: [itemToString],
});

if (
search.trim() !== "" &&
itemToString(matched[0]).toLocaleLowerCase() !==
search.toLocaleLowerCase().trim()
) {
matched.unshift({
value: search.trim(),
label: `Custom Font: "${search.trim()}"`,
});
}
return matched;
};

export const FontFamilyControl = ({
property,
currentStyle,
setProperty,
}: ControlProps) => {
const value = currentStyle[property]?.value;
const [isOpen, setIsOpen] = useState(false);

const setValue = setProperty(property);
const [intermediateValue, setIntermediateValue] = useState<
string | undefined
>();
const { assetContainers } = useAssets("font");
const items = useMemo(() => {
const fallbacks = styleConfigByName("fontFamily").items;
return [...toItems(assetContainers), ...fallbacks].map(({ label }) => ({
value: label,
}));
}, [assetContainers]);

const itemValue = useMemo(() => {
// Replacing the quotes just to make it look cleaner in the UI
return toValue(value, (value) => value).replace(/"/g, "");
}, [value]);

return (
<FloatingPanel
title="Fonts"
content={
<FontsManager
value={toValue(value)}
onChange={(newValue) => {
setValue({ type: "fontFamily", value: [newValue] });
}}
/>
}
onOpenChange={setIsOpen}
>
<DeprecatedTextField
// Replacing the quotes just to make it look cleaner in the UI
defaultValue={toValue(value).replace(/"/, "")}
state={isOpen ? "active" : undefined}
<Flex>
<Combobox<Item>
suffix={
<FloatingPanel
title="Fonts"
content={
<FontsManager
value={toValue(value)}
onChange={(newValue) => {
setValue({ type: "fontFamily", value: [newValue] });
}}
/>
}
>
<FontsManagerButton />
</FloatingPanel>
}
defaultHighlightedIndex={0}
items={items}
itemToString={(item) => item?.label ?? item?.value ?? ""}
onItemHighlight={(item) => {
const value = item === null ? itemValue : item.value;
setValue(
{ type: "fontFamily", value: [value] },
{ isEphemeral: true }
);
}}
onItemSelect={(item) => {
setValue(parseCssValue("fontFamily", item.value));
setIntermediateValue(undefined);
}}
value={{ value: intermediateValue ?? itemValue }}
onInputChange={(value) => {
setIntermediateValue(value);
}}
match={matchOrSuggestToCreate}
/>
</FloatingPanel>
</Flex>
);
};

const FontsManagerButton = forwardRef<
HTMLButtonElement,
ComponentProps<typeof NestedInputButton>
>((props, ref) => {
return (
<Flex>
<EnhancedTooltip content="Open Font Manager">
<NestedInputButton {...props} ref={ref} tabIndex={-1}>
<UploadIcon />
</NestedInputButton>
</EnhancedTooltip>
</Flex>
);
});
FontsManagerButton.displayName = "FontsManagerButton";
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type FontWeightItem = {
const allFontWeights: Array<FontWeightItem> = (
Object.keys(fontWeights) as Array<FontWeight>
).map((weight) => ({
label: `${fontWeights[weight].label} (${weight})`,
label: `${weight} - ${fontWeights[weight].label}`,
weight,
}));

Expand Down
4 changes: 2 additions & 2 deletions apps/builder/app/builder/features/topbar/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { $authPermit, $authTokenPermissions } from "~/shared/nano-states";
import { emitCommand } from "~/builder/shared/commands";
import { MenuButton } from "./menu-button";
import { $isProjectSettingsOpen } from "~/shared/nano-states/seo";
import { UploadIcon } from "@webstudio-is/icons";
import { UpgradeIcon } from "@webstudio-is/icons";

const ViewMenuItem = () => {
const [clientSettings, setClientSetting] = useClientSettings();
Expand Down Expand Up @@ -213,7 +213,7 @@ export const Menu = () => {
}}
css={{ gap: theme.spacing[3] }}
>
<UploadIcon />
<UpgradeIcon />
<div>Upgrade to Pro</div>
</DropdownMenuItem>
</>
Expand Down
1 change: 1 addition & 0 deletions apps/builder/app/builder/shared/fonts-manager/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./fonts-manager";
export { toItems } from "./item-utils";
4 changes: 2 additions & 2 deletions apps/builder/app/dashboard/header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
ChevronDownIcon,
UploadIcon,
UpgradeIcon,
WebstudioIcon,
} from "@webstudio-is/icons";
import {
Expand Down Expand Up @@ -95,7 +95,7 @@ const Menu = ({
gap: theme.spacing[3],
}}
>
<UploadIcon />
<UpgradeIcon />
<div>Upgrade to Pro</div>
</DropdownMenuItem>
)}
Expand Down
15 changes: 7 additions & 8 deletions packages/css-engine/src/core/to-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ const fallbackTransform: TransformValue = (styleValue) => {
if (styleValue.type === "fontFamily") {
const firstFontFamily = styleValue.value[0];

const fallbacks = SYSTEM_FONTS.get(firstFontFamily ?? "Arial");
const fontFamily: string[] = [...styleValue.value];
if (Array.isArray(fallbacks)) {
fontFamily.push(...fallbacks);
} else {
fontFamily.push(DEFAULT_FONT_FALLBACK);
}
const fontFamily = styleValue.value;
const fallbacks = SYSTEM_FONTS.get(firstFontFamily) ?? [
DEFAULT_FONT_FALLBACK,
];
const value = Array.from(new Set([...fontFamily, ...fallbacks]));

return {
type: "fontFamily",
value: fontFamily,
value,
};
}
};
Expand Down
12 changes: 9 additions & 3 deletions packages/design-system/src/components/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -450,13 +450,15 @@ export const useCombobox = <Item,>({
type ComboboxProps<Item> = UseComboboxProps<Item> &
Omit<ComponentProps<"input">, "value"> & {
color?: ComponentProps<typeof InputField>["color"];
suffix?: ComponentProps<typeof InputField>["suffix"];
};

export const Combobox = <Item,>({
autoFocus,
getDescription,
placeholder,
color,
suffix,
...props
}: ComboboxProps<Item>) => {
const combobox = useCombobox<Item>(props);
Expand All @@ -471,14 +473,18 @@ export const Combobox = <Item,>({

return (
<ComboboxRoot open={combobox.isOpen}>
<div {...combobox.getComboboxProps()}>
<Box {...combobox.getComboboxProps()}>
<ComboboxAnchor>
<InputField
{...combobox.getInputProps()}
placeholder={placeholder}
autoFocus={autoFocus}
color={color}
suffix={<NestedInputButton {...combobox.getToggleButtonProps()} />}
suffix={
suffix ?? (
<NestedInputButton {...combobox.getToggleButtonProps()} />
)
}
/>
</ComboboxAnchor>
<ComboboxContent>
Expand All @@ -504,7 +510,7 @@ export const Combobox = <Item,>({
)}
</ComboboxListbox>
</ComboboxContent>
</div>
</Box>
</ComboboxRoot>
);
};
2 changes: 2 additions & 0 deletions packages/fonts/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const SYSTEM_FONTS = new Map([
["Times New Roman", ["sans"]],
["Courier New", ["monospace"]],
["system-ui", []],
["SimSun", ["Songti SC, sans-serif"]],
["PingFang SC", ["Microsoft Ya Hei", "sans-serif"]],
]);

export const DEFAULT_FONT_FALLBACK = "sans-serif";
Expand Down
4 changes: 4 additions & 0 deletions packages/icons/icons/upgrade.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 14 additions & 4 deletions packages/icons/icons/upload.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 25 additions & 1 deletion packages/icons/src/__generated__/components.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/icons/src/__generated__/svg.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 918ca2f

Please sign in to comment.