feat: introduce font picker (#8012)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Marcel Mraz 2024-07-25 18:55:55 +02:00 committed by GitHub
parent 4c5408263c
commit 62228e0bbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
120 changed files with 3390 additions and 1106 deletions

View file

@ -158,10 +158,8 @@ export const SelectedShapeActions = ({
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}

View file

@ -321,7 +321,6 @@ import {
getBoundTextElement,
getContainerCenter,
getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
@ -337,7 +336,7 @@ import {
import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts";
import { Fonts, getLineHeight } from "../fonts";
import {
getFrameChildren,
isCursorInFrame,
@ -532,8 +531,8 @@ class App extends React.Component<AppProps, AppState> {
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
public scene: Scene;
public fonts: Fonts;
public renderer: Renderer;
private fonts: Fonts;
private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"];
@ -2335,11 +2334,6 @@ class App extends React.Component<AppProps, AppState> {
}),
};
}
// FontFaceSet loadingdone event we listen on may not always fire
// (looking at you Safari), so on init we manually load fonts for current
// text elements on canvas, and rerender them once done. This also
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadFontsForElements(scene.elements);
this.resetStore();
this.resetHistory();
@ -2347,6 +2341,12 @@ class App extends React.Component<AppProps, AppState> {
...scene,
storeAction: StoreAction.UPDATE,
});
// FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all
// fonts and rerender scene text elements once done. This also
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.load();
};
private isMobileBreakpoint = (width: number, height: number) => {
@ -2439,6 +2439,10 @@ class App extends React.Component<AppProps, AppState> {
configurable: true,
value: this.store,
},
fonts: {
configurable: true,
value: this.fonts,
},
});
}
@ -2576,7 +2580,7 @@ class App extends React.Component<AppProps, AppState> {
// rerender text elements on font load to fix #637 && #1553
addEventListener(document.fonts, "loadingdone", (event) => {
const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
this.fonts.onFontsLoaded(loadedFontFaces);
this.fonts.onLoaded(loadedFontFaces);
}),
// Safari-only desktop pinch zoom
addEventListener(
@ -3379,7 +3383,7 @@ class App extends React.Component<AppProps, AppState> {
fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily,
});
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
const lineHeight = getLineHeight(textElementProps.fontFamily);
const [x1, , x2] = getVisibleSceneBounds(this.state);
// long texts should not go beyond 800 pixels in width nor should it go below 200 px
const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
@ -3397,13 +3401,13 @@ class App extends React.Component<AppProps, AppState> {
});
let metrics = measureText(originalText, fontString, lineHeight);
const isTextWrapped = metrics.width > maxTextWidth;
const isTextUnwrapped = metrics.width > maxTextWidth;
const text = isTextWrapped
const text = isTextUnwrapped
? wrapText(originalText, fontString, maxTextWidth)
: originalText;
metrics = isTextWrapped
metrics = isTextUnwrapped
? measureText(text, fontString, lineHeight)
: metrics;
@ -3417,7 +3421,7 @@ class App extends React.Component<AppProps, AppState> {
text,
originalText,
lineHeight,
autoResize: !isTextWrapped,
autoResize: !isTextUnwrapped,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
acc.push(element);
@ -4107,6 +4111,36 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (
!event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.F
) {
const selectedElements = this.scene.getSelectedElements(this.state);
if (
this.state.activeTool.type === "selection" &&
!selectedElements.length
) {
return;
}
if (
this.state.activeTool.type === "text" ||
selectedElements.find(
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
this.scene.getNonDeletedElementsMap(),
),
)
) {
event.preventDefault();
this.setState({ openPopup: "fontFamily" });
}
}
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" });
@ -4761,7 +4795,7 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight =
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
existingTextElement?.lineHeight || getLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize;
if (

View file

@ -0,0 +1,12 @@
@import "../css/theme";
.excalidraw {
button.standalone {
@include outlineButtonIconStyles;
& > * {
// dissalow pointer events on children, so we always have event.target on the button itself
pointer-events: none;
}
}
}

View file

@ -0,0 +1,36 @@
import { forwardRef } from "react";
import clsx from "clsx";
import "./ButtonIcon.scss";
interface ButtonIconProps {
icon: JSX.Element;
title: string;
className?: string;
testId?: string;
/** if not supplied, defaults to value identity check */
active?: boolean;
/** include standalone style (could interfere with parent styles) */
standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
(props, ref) => {
const { title, className, testId, active, standalone, icon, onClick } =
props;
return (
<button
type="button"
ref={ref}
key={title}
title={title}
data-testid={testId}
className={clsx(className, { standalone, active })}
onClick={onClick}
>
{icon}
</button>
);
},
);

View file

@ -1,4 +1,5 @@
import clsx from "clsx";
import { ButtonIcon } from "./ButtonIcon";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
@ -24,21 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
}
),
) => (
<div className="buttonList buttonListIcon">
<div className="buttonList">
{props.options.map((option) =>
props.type === "button" ? (
<button
type="button"
<ButtonIcon
key={option.text}
onClick={(event) => props.onClick(option.value, event)}
className={clsx({
active: option.active ?? props.value === option.value,
})}
data-testid={option.testId}
icon={option.icon}
title={option.text}
>
{option.icon}
</button>
testId={option.testId}
active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)}
/>
) : (
<label
key={option.text}

View file

@ -0,0 +1,10 @@
export const ButtonSeparator = () => (
<div
style={{
width: 1,
height: "1rem",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
);

View file

@ -20,7 +20,7 @@
align-items: center;
@include isMobile {
max-width: 175px;
max-width: 11rem;
}
}

View file

@ -1,22 +1,24 @@
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import { isTransparent } from "../../utils";
import type { ExcalidrawElement } from "../../element/types";
import type { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useDevice, useExcalidrawContainer } from "../App";
import { useExcalidrawContainer } from "../App";
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
import { COLOR_PALETTE } from "../../colors";
import PickerHeading from "./PickerHeading";
import { t } from "../../i18n";
import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import "./ColorPicker.scss";
@ -71,6 +73,7 @@ const ColorPickerPopupContent = ({
| "palette"
| "updateData"
>) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
@ -78,9 +81,6 @@ const ColorPickerPopupContent = ({
jotaiScope,
);
const { container } = useExcalidrawContainer();
const device = useDevice();
const colorInputJSX = (
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
@ -94,6 +94,7 @@ const ColorPickerPopupContent = ({
/>
</div>
);
const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
@ -103,120 +104,73 @@ const ColorPickerPopupContent = ({
};
return (
<Popover.Portal container={container}>
<Popover.Content
ref={popoverRef}
className="focus-visible-none"
data-prevent-outside-click
onFocusOutside={(event) => {
focusPickerContent();
<PropertiesPopover
container={container}
style={{ maxWidth: "208px" }}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: "var(--zIndex-layerUI)",
backgroundColor: "var(--popup-bg-color)",
maxWidth: "208px",
maxHeight: window.innerHeight,
padding: "12px",
borderRadius: "8px",
boxSizing: "border-box",
overflowY: "auto",
boxShadow:
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
}}
onClose={() => {
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
});
}}
/>
</Popover.Content>
</Popover.Portal>
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
);
};
@ -232,7 +186,7 @@ const ColorPickerTrigger = ({
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color", {
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color,
})}
aria-label={label}
@ -268,14 +222,7 @@ export const ColorPicker = ({
type={type}
topPicks={topPicks}
/>
<div
style={{
width: 1,
height: "100%",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
<ButtonSeparator />
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {

View file

@ -138,7 +138,7 @@ export const Picker = ({
event.stopPropagation();
}
}}
className="color-picker-content"
className="color-picker-content properties-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>

View file

@ -0,0 +1,15 @@
@import "../../css/variables.module.scss";
.excalidraw {
.FontPicker__container {
display: grid;
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
align-items: center;
@include isMobile {
max-width: calc(
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
}
}

View file

@ -0,0 +1,110 @@
import React, { useCallback, useMemo } from "react";
import * as Popover from "@radix-ui/react-popover";
import { FontPickerList } from "./FontPickerList";
import { FontPickerTrigger } from "./FontPickerTrigger";
import { ButtonIconSelect } from "../ButtonIconSelect";
import {
FontFamilyCodeIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../icons";
import { ButtonSeparator } from "../ButtonSeparator";
import type { FontFamilyValues } from "../../element/types";
import { FONT_FAMILY } from "../../constants";
import { t } from "../../i18n";
import "./FontPicker.scss";
export const DEFAULT_FONTS = [
{
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-handrawn",
},
{
value: FONT_FAMILY.Nunito,
icon: FontFamilyNormalIcon,
text: t("labels.normal"),
testId: "font-family-normal",
},
{
value: FONT_FAMILY["Comic Shanns"],
icon: FontFamilyCodeIcon,
text: t("labels.code"),
testId: "font-family-code",
},
];
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
export const isDefaultFont = (fontFamily: number | null) => {
if (!fontFamily) {
return false;
}
return defaultFontFamilies.has(fontFamily);
};
interface FontPickerProps {
isOpened: boolean;
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (fontFamily: FontFamilyValues) => void;
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
}
export const FontPicker = React.memo(
({
isOpened,
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onPopupChange,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
(value: number | false) => {
if (value) {
onSelect(value);
}
},
[onSelect],
);
return (
<div role="dialog" aria-modal="true" className="FontPicker__container">
<ButtonIconSelect<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={hoveredFontFamily}
onSelect={onSelectCallback}
onHover={onHover}
onLeave={onLeave}
onOpen={() => onPopupChange(true)}
onClose={() => onPopupChange(false)}
/>
)}
</Popover.Root>
</div>
);
},
(prev, next) =>
prev.isOpened === next.isOpened &&
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

View file

@ -0,0 +1,268 @@
import React, {
useMemo,
useState,
useRef,
useEffect,
useCallback,
type KeyboardEventHandler,
} from "react";
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import { type FontFamilyValues } from "../../element/types";
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types";
export interface FontDescriptor {
value: number;
icon: JSX.Element;
text: string;
deprecated?: true;
badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>;
placeholder: string;
};
}
interface FontPickerListProps {
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (value: number) => void;
onHover: (value: number) => void;
onLeave: () => void;
onOpen: () => void;
onClose: () => void;
}
export const FontPickerList = React.memo(
({
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onOpen,
onClose,
}: FontPickerListProps) => {
const { container } = useExcalidrawContainer();
const { fonts } = useApp();
const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fontFaces }]) => {
const font = {
value: familyId,
icon: metadata.icon,
text: fontFaces[0].fontFace.family,
};
if (metadata.deprecated) {
Object.assign(font, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
}
return font as FontDescriptor;
})
.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
),
[],
);
const sceneFamilies = useMemo(
() => new Set(fonts.sceneFamilies),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
);
const sceneFonts = useMemo(
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
[allFonts, sceneFamilies],
);
const availableFonts = useMemo(
() =>
allFonts.filter(
(font) =>
!sceneFamilies.has(font.value) &&
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
),
[allFonts, sceneFamilies, showDeprecatedFonts],
);
const filteredFonts = useMemo(
() =>
arrayToList(
[...sceneFonts, ...availableFonts].filter((font) =>
font.text?.toLowerCase().includes(searchTerm),
),
),
[sceneFonts, availableFonts, searchTerm],
);
const hoveredFont = useMemo(() => {
let font;
if (hoveredFontFamily) {
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
} else if (selectedFontFamily) {
font = filteredFonts.find((font) => font.value === selectedFontFamily);
}
if (!font && searchTerm) {
if (filteredFonts[0]?.value) {
// hover first element on search
onHover(filteredFonts[0].value);
} else {
// re-render cache on no results
onLeave();
}
}
return font;
}, [
hoveredFontFamily,
selectedFontFamily,
searchTerm,
filteredFonts,
onHover,
onLeave,
]);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
const handled = fontPickerKeyHandler({
event,
inputRef,
hoveredFont,
filteredFonts,
onSelect,
onHover,
onClose,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
);
useEffect(() => {
onOpen();
return () => {
onClose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sceneFilteredFonts = useMemo(
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const availableFilteredFonts = useMemo(
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
key={font.value}
icon={font.icon}
value={font.value}
order={index}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
onSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
{font.text}
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)}
</DropdownMenuItem>
);
const groups = [];
if (sceneFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map(renderFont)}
</DropdownMenuGroup>,
);
}
if (availableFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) =>
renderFont(font, index + sceneFilteredFonts.length),
)}
</DropdownMenuGroup>,
);
}
return (
<PropertiesPopover
className="properties-content"
container={container}
style={{ width: "15rem" }}
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<QuickSearch
ref={inputRef}
placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)}
/>
<ScrollableList
className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")}
>
{groups.length ? groups : null}
</ScrollableList>
</PropertiesPopover>
);
},
(prev, next) =>
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

View file

@ -0,0 +1,38 @@
import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
}
export const FontPickerTrigger = ({
selectedFontFamily,
}: FontPickerTriggerProps) => {
const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
return (
<Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op
onClick={() => {}}
/>
</div>
</Popover.Trigger>
);
};

View file

@ -0,0 +1,66 @@
import type { Node } from "../../utils";
import { KEYS } from "../../keys";
import { type FontDescriptor } from "./FontPickerList";
interface FontPickerKeyNavHandlerProps {
event: React.KeyboardEvent<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
hoveredFont: Node<FontDescriptor> | undefined;
filteredFonts: Node<FontDescriptor>[];
onClose: () => void;
onSelect: (value: number) => void;
onHover: (value: number) => void;
}
export const fontPickerKeyHandler = ({
event,
inputRef,
hoveredFont,
filteredFonts,
onClose,
onSelect,
onHover,
}: FontPickerKeyNavHandlerProps) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.F
) {
// refocus input on the popup trigger shortcut
inputRef.current?.focus();
return true;
}
if (event.key === KEYS.ESCAPE) {
onClose();
return true;
}
if (event.key === KEYS.ENTER) {
if (hoveredFont?.value) {
onSelect(hoveredFont.value);
}
return true;
}
if (event.key === KEYS.ARROW_DOWN) {
if (hoveredFont?.next) {
onHover(hoveredFont.next.value);
} else if (filteredFonts[0]?.value) {
onHover(filteredFonts[0].value);
}
return true;
}
if (event.key === KEYS.ARROW_UP) {
if (hoveredFont?.prev) {
onHover(hoveredFont.prev.value);
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
onHover(filteredFonts[filteredFonts.length - 1].value);
}
return true;
}
};

View file

@ -8,7 +8,7 @@
h3 {
margin: 1.5rem 0;
font-weight: bold;
font-weight: 700;
font-size: 1.125rem;
}
@ -82,7 +82,7 @@
&__island {
h4 {
font-size: 1rem;
font-weight: bold;
font-weight: 700;
margin: 0;
margin-bottom: 0.625rem;
}

View file

@ -458,6 +458,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]}
/>
<Shortcut
label={t("labels.showFonts")}
shortcuts={[getShortcutKey("Shift+F")]}
/>
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}

View file

@ -11,7 +11,7 @@
.library-actions-counter {
background-color: var(--color-primary);
color: var(--color-primary-light);
font-weight: bold;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;

View file

@ -13,7 +13,7 @@
&__label {
color: var(--color-primary);
font-weight: bold;
font-weight: 700;
font-size: 1.125rem;
margin-bottom: 0.75rem;
}
@ -62,7 +62,7 @@
&__header {
color: var(--color-primary);
font-size: 1.125rem;
font-weight: bold;
font-weight: 700;
margin-bottom: 0.75rem;
width: 100%;
padding-right: 4rem; // due to dropdown button

View file

@ -0,0 +1,96 @@
import React, { type ReactNode } from "react";
import clsx from "clsx";
import * as Popover from "@radix-ui/react-popover";
import { useDevice } from "./App";
import { Island } from "./Island";
import { isInteractive } from "../utils";
interface PropertiesPopoverProps {
className?: string;
container: HTMLDivElement | null;
children: ReactNode;
style?: object;
onClose: () => void;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
onFocusOutside?: Popover.DismissableLayerProps["onFocusOutside"];
onPointerDownOutside?: Popover.DismissableLayerProps["onPointerDownOutside"];
}
export const PropertiesPopover = React.forwardRef<
HTMLDivElement,
PropertiesPopoverProps
>(
(
{
className,
container,
children,
style,
onClose,
onKeyDown,
onFocusOutside,
onPointerLeave,
onPointerDownOutside,
},
ref,
) => {
const device = useDevice();
return (
<Popover.Portal container={container}>
<Popover.Content
ref={ref}
className={clsx("focus-visible-none", className)}
data-prevent-outside-click
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: "var(--zIndex-popup)",
}}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
onFocusOutside={onFocusOutside}
onPointerDownOutside={onPointerDownOutside}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
onClose();
}}
>
<Island padding={3} style={style}>
{children}
</Island>
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
/>
</Popover.Content>
</Popover.Portal>
);
},
);

View file

@ -133,7 +133,7 @@
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-weight: 700;
font-size: 1rem;
margin: 0.2rem;
}

View file

@ -0,0 +1,48 @@
.excalidraw {
--list-border-color: var(--color-gray-20);
.QuickSearch__wrapper {
position: relative;
height: 2.6rem; // added +0.1 due to Safari
border-bottom: 1px solid var(--list-border-color);
svg {
position: absolute;
top: 47.5%; // 50% is not exactly in the center of the input
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
}
&.theme--dark {
--list-border-color: var(--color-gray-80);
.QuickSearch__wrapper {
border-bottom: none;
}
}
.QuickSearch__input {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
border: 0 !important;
font-size: 0.875rem;
padding-left: 2.5rem !important;
padding-right: 0.75rem !important;
&::placeholder {
color: var(--color-gray-40);
}
&:focus {
box-shadow: none !important;
}
}
}

View file

@ -0,0 +1,28 @@
import clsx from "clsx";
import React from "react";
import { searchIcon } from "./icons";
import "./QuickSearch.scss";
interface QuickSearchProps {
className?: string;
placeholder: string;
onChange: (term: string) => void;
}
export const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
({ className, placeholder, onChange }, ref) => {
return (
<div className={clsx("QuickSearch__wrapper", className)}>
{searchIcon}
<input
ref={ref}
className="QuickSearch__input"
type="text"
placeholder={placeholder}
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
/>
</div>
);
},
);

View file

@ -0,0 +1,21 @@
.excalidraw {
.ScrollableList__wrapper {
position: static !important;
border: none;
font-size: 0.875rem;
overflow-y: auto;
& > .empty,
& > .hint {
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
font-size: 0.75rem;
color: var(--color-gray-60);
overflow: hidden;
text-align: center;
line-height: 150%;
}
}
}

View file

@ -0,0 +1,24 @@
import clsx from "clsx";
import { Children } from "react";
import "./ScrollableList.scss";
interface ScrollableListProps {
className?: string;
placeholder: string;
children: React.ReactNode;
}
export const ScrollableList = ({
className,
placeholder,
children,
}: ScrollableListProps) => {
const isEmpty = !Children.count(children);
return (
<div className={clsx("ScrollableList__wrapper", className)} role="menu">
{isEmpty ? <div className="empty">{placeholder}</div> : children}
</div>
);
};

View file

@ -139,7 +139,7 @@ $verticalBreakpoint: 861px;
.ttd-dialog-output-error {
color: red;
font-weight: 800;
font-weight: 700;
font-size: 30px;
word-break: break-word;
overflow: auto;

View file

@ -5,10 +5,11 @@
--avatarList-gap: 0.625rem;
--userList-padding: var(--space-factor);
.UserList-wrapper {
.UserList__wrapper {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
pointer-events: none !important;
}
@ -21,10 +22,6 @@
align-items: center;
gap: var(--avatarList-gap);
&:empty {
display: none;
}
box-sizing: border-box;
--max-size: calc(
@ -157,66 +154,7 @@
}
.UserList__collaborators {
position: static;
top: auto;
margin-top: 0;
max-height: 50vh;
overflow-y: auto;
padding: 0.25rem 0.5rem;
border-top: 1px solid var(--userlist-collaborators-border-color);
border-bottom: 1px solid var(--userlist-collaborators-border-color);
&__empty {
color: var(--color-gray-60);
font-size: 0.75rem;
line-height: 150%;
padding: 0.5rem 0;
}
}
.UserList__hint {
padding: 0.5rem 0.75rem;
overflow: hidden;
text-align: center;
color: var(--userlist-hint-text-color);
font-size: 0.75rem;
line-height: 150%;
}
.UserList__search-wrapper {
position: relative;
height: 2.5rem;
svg {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
}
.UserList__search {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
border: 0 !important;
border-radius: 0 !important;
font-size: 0.875rem;
padding-left: 2.5rem !important;
padding-right: 0.75rem !important;
&::placeholder {
color: var(--color-gray-40);
}
&:focus {
box-shadow: none !important;
}
}
}

View file

@ -9,11 +9,12 @@ import type { ActionManager } from "../actions/manager";
import * as Popover from "@radix-ui/react-popover";
import { Island } from "./Island";
import { searchIcon } from "./icons";
import { QuickSearch } from "./QuickSearch";
import { t } from "../i18n";
import { isShallowEqual } from "../utils";
import { supportsResizeObserver } from "../constants";
import type { MarkRequired } from "../utility-types";
import { ScrollableList } from "./ScrollableList";
export type GoToCollaboratorComponentProps = {
socketId: SocketId;
@ -40,7 +41,7 @@ const ConditionalTooltipWrapper = ({
shouldWrap ? (
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
) : (
<React.Fragment>{children}</React.Fragment>
<>{children}</>
);
const renderCollaborator = ({
@ -128,6 +129,10 @@ export const UserList = React.memo(
).filter((collaborator) => collaborator.username?.trim());
const [searchTerm, setSearchTerm] = React.useState("");
const filteredCollaborators = uniqueCollaboratorsArray.filter(
(collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
);
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
@ -161,14 +166,6 @@ export const UserList = React.memo(
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
const searchTermNormalized = searchTerm.trim().toLowerCase();
const filteredCollaborators = searchTermNormalized
? uniqueCollaboratorsArray.filter((collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
)
: uniqueCollaboratorsArray;
const firstNCollaborators = uniqueCollaboratorsArray.slice(
0,
maxAvatars - 1,
@ -197,7 +194,7 @@ export const UserList = React.memo(
)}
</div>
) : (
<div className="UserList-wrapper" ref={userListWrapper}>
<div className="UserList__wrapper" ref={userListWrapper}>
<div
className={clsx("UserList", className)}
style={{ [`--max-avatars` as any]: maxAvatars }}
@ -205,13 +202,7 @@ export const UserList = React.memo(
{firstNAvatarsJSX}
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
<Popover.Root
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
>
<Popover.Root>
<Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
</Popover.Trigger>
@ -224,41 +215,43 @@ export const UserList = React.memo(
align="end"
sideOffset={10}
>
<Island style={{ overflow: "hidden" }}>
<Island padding={2}>
{uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && (
<div className="UserList__search-wrapper">
{searchIcon}
<input
className="UserList__search"
type="text"
placeholder={t("userList.search.placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
</div>
<QuickSearch
placeholder={t("quickSearch.placeholder")}
onChange={setSearchTerm}
/>
)}
<div className="dropdown-menu UserList__collaborators">
{filteredCollaborators.length === 0 && (
<div className="UserList__collaborators__empty">
{t("userList.search.empty")}
</div>
)}
<div className="UserList__hint">
{t("userList.hint.text")}
</div>
{filteredCollaborators.map((collaborator) =>
renderCollaborator({
actionManager,
collaborator,
socketId: collaborator.socketId,
withName: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
)}
</div>
<ScrollableList
className={"dropdown-menu UserList__collaborators"}
placeholder={t("userList.empty")}
>
{/* The list checks for `Children.count()`, hence defensively returning empty list */}
{filteredCollaborators.length > 0
? [
<div className="hint">{t("userList.hint.text")}</div>,
filteredCollaborators.map((collaborator) =>
renderCollaborator({
actionManager,
collaborator,
socketId: collaborator.socketId,
withName: true,
isBeingFollowed:
collaborator.socketId === userToFollow,
}),
),
]
: []}
</ScrollableList>
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
/>
</Island>
</Popover.Content>
</Popover.Root>

View file

@ -105,6 +105,7 @@ const getRelevantAppStateProps = (
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
});
const areEqual = (

View file

@ -4,7 +4,7 @@
.dropdown-menu {
position: absolute;
top: 100%;
margin-top: 0.25rem;
margin-top: 0.5rem;
&--mobile {
left: 0;
@ -35,21 +35,69 @@
.dropdown-menu-item-base {
display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-on-surface);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-weight: 400;
font-family: inherit;
}
&.manual-hover {
// disable built-in hover due to keyboard navigation
.dropdown-menu-item {
&:hover {
background-color: transparent;
}
&--hovered {
background-color: var(--button-hover-bg) !important;
}
&--selected {
background-color: var(--color-primary-light) !important;
}
}
}
&.fonts {
margin-top: 1rem;
// display max 7 items per list, where each has 2rem (2.25) height and 1px margin top & bottom
// count in 2 groups, where each allocates 1.3*0.75rem font-size and 0.5rem margin bottom, plus one extra 1rem margin top
max-height: calc(7 * (2rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem);
@media screen and (min-width: 1921px) {
max-height: calc(
7 * (2.25rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem
);
}
.dropdown-menu-item-base {
display: inline-flex;
}
.dropdown-menu-group:not(:first-child) {
margin-top: 1rem;
}
.dropdown-menu-group-title {
font-size: 0.75rem;
text-align: left;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.3;
}
}
.dropdown-menu-item {
height: 2rem;
margin: 1px;
padding: 0 0.5rem;
width: calc(100% - 2px);
background-color: transparent;
border: 1px solid transparent;
align-items: center;
height: 2rem;
cursor: pointer;
border-radius: var(--border-radius-md);
@ -57,11 +105,6 @@
height: 2.25rem;
}
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&__text {
display: flex;
align-items: center;
@ -83,6 +126,11 @@
}
}
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&:hover {
background-color: var(--button-hover-bg);
text-decoration: none;

View file

@ -1,37 +1,62 @@
import React from "react";
import React, { useEffect, useRef } from "react";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
import { useExcalidrawAppState } from "../App";
import { THEME } from "../../constants";
import type { ValueOf } from "../../utility-types";
const DropdownMenuItem = ({
icon,
onSelect,
value,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
...rest
}: {
icon?: JSX.Element;
onSelect: (event: Event) => void;
value?: string | number | undefined;
order?: number;
onSelect?: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return (
<button
{...rest}
ref={ref}
value={value}
onClick={handleClick}
type="button"
className={getDropdownMenuItemClassName(className, selected)}
className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</button>
@ -39,24 +64,53 @@ const DropdownMenuItem = ({
};
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropDownMenuItemBadgeType = {
GREEN: "green",
RED: "red",
BLUE: "blue",
} as const;
export const DropDownMenuItemBadge = ({
type = DropDownMenuItemBadgeType.BLUE,
children,
}: {
type?: ValueOf<typeof DropDownMenuItemBadgeType>;
children: React.ReactNode;
}) => {
return (
<div
style={{
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
const { theme } = useExcalidrawAppState();
const style = {
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
};
switch (type) {
case DropDownMenuItemBadgeType.GREEN:
Object.assign(style, {
backgroundColor: "var(--background-color-badge)",
color: "var(--color-badge)",
});
break;
case DropDownMenuItemBadgeType.RED:
Object.assign(style, {
backgroundColor: "pink",
color: "darkred",
});
break;
case DropDownMenuItemBadgeType.BLUE:
default:
Object.assign(style, {
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
}}
>
});
}
return (
<div className="DropDownMenuItemBadge" style={style}>
{children}
</div>
);

View file

@ -1,19 +1,23 @@
import { useDevice } from "../App";
const MenuItemContent = ({
textStyle,
icon,
shortcut,
children,
}: {
icon?: JSX.Element;
shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
<div className="dropdown-menu-item__icon">{icon}</div>
<div className="dropdown-menu-item__text">{children}</div>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
{children}
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}

View file

@ -9,9 +9,11 @@ export const DropdownMenuContentPropsContext = React.createContext<{
export const getDropdownMenuItemClassName = (
className = "",
selected = false,
hovered = false,
) => {
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
selected ? "dropdown-menu-item--selected" : ""
return `dropdown-menu-item dropdown-menu-item-base ${className}
${selected ? "dropdown-menu-item--selected" : ""} ${
hovered ? "dropdown-menu-item--hovered" : ""
}`.trim();
};

View file

@ -1438,6 +1438,27 @@ export const fontSizeIcon = createIcon(
tablerIconProps,
);
export const FontFamilyHeadingIcon = createIcon(
<>
<g
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 12h10" />
<path d="M7 5v14" />
<path d="M17 5v14" />
<path d="M15 19h4" />
<path d="M15 5h4" />
<path d="M5 19h4" />
<path d="M5 5h4" />
</g>
</>,
tablerIconProps,
);
export const FontFamilyNormalIcon = createIcon(
<>
<g

View file

@ -109,7 +109,7 @@ Center.displayName = "Center";
const Logo = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="welcome-screen-center__logo virgil welcome-screen-decor">
<div className="welcome-screen-center__logo excalifont welcome-screen-decor">
{children || <ExcalidrawLogo withText />}
</div>
);
@ -118,7 +118,7 @@ Logo.displayName = "Logo";
const Heading = ({ children }: { children: React.ReactNode }) => {
return (
<div className="welcome-screen-center__heading welcome-screen-decor virgil">
<div className="welcome-screen-center__heading welcome-screen-decor excalifont">
{children}
</div>
);

View file

@ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenMenuHintTunnel } = useTunnels();
return (
<WelcomeScreenMenuHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
{WelcomeScreenMenuArrow}
<div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.menuHint")}
@ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenToolbarHintTunnel } = useTunnels();
return (
<WelcomeScreenToolbarHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.toolbarHint")}
</div>
@ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenHelpHintTunnel } = useTunnels();
return (
<WelcomeScreenHelpHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div>{children || t("welcomeScreen.defaults.helpHint")}</div>
{WelcomeScreenHelpArrow}
</div>

View file

@ -1,6 +1,6 @@
.excalidraw {
.virgil {
font-family: "Virgil";
.excalifont {
font-family: "Excalifont";
}
// WelcomeSreen common