mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: introduce font picker (#8012)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
4c5408263c
commit
62228e0bbb
120 changed files with 3390 additions and 1106 deletions
|
@ -155,13 +155,15 @@ describe("element locking", () => {
|
|||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
fontFamily: FONT_FAMILY.Cascadia,
|
||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||
});
|
||||
h.elements = [rect, text];
|
||||
API.setSelectedElements([rect, text]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||
"active",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import type { StoreActionType } from "../store";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
|
@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
|
|||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
||||
// ArrowHead icons
|
||||
|
@ -38,9 +41,6 @@ import {
|
|||
FontSizeExtraLargeIcon,
|
||||
EdgeSharpIcon,
|
||||
EdgeRoundIcon,
|
||||
FreedrawIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyCodeIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignRightIcon,
|
||||
|
@ -65,10 +65,7 @@ import {
|
|||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
|
@ -94,9 +91,10 @@ import {
|
|||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "../store";
|
||||
import { Fonts, getLineHeight } from "../fonts";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
|
@ -729,104 +727,391 @@ export const actionIncreaseFontSize = register({
|
|||
},
|
||||
});
|
||||
|
||||
type ChangeFontFamilyData = Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
|
||||
>
|
||||
> & {
|
||||
/** cache of selected & editing elements populated on opened popup */
|
||||
cachedElements?: Map<string, ExcalidrawElement>;
|
||||
/** flag to reset all elements to their cached versions */
|
||||
resetAll?: true;
|
||||
/** flag to reset all containers to their cached versions */
|
||||
resetContainers?: true;
|
||||
};
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
label: "labels.fontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
|
||||
value as ChangeFontFamilyData;
|
||||
|
||||
if (resetAll) {
|
||||
const nextElements = changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(oldElement) => {
|
||||
if (isTextElement(oldElement)) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: value,
|
||||
lineHeight: getDefaultLineHeight(value),
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
(element) => {
|
||||
const cachedElement = cachedElements?.get(element.id);
|
||||
if (cachedElement) {
|
||||
const newElement = newElementWith(element, {
|
||||
...cachedElement,
|
||||
});
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
return oldElement;
|
||||
return element;
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...appState,
|
||||
...nextAppState,
|
||||
},
|
||||
storeAction: StoreAction.UPDATE,
|
||||
};
|
||||
}
|
||||
|
||||
const { currentItemFontFamily, currentHoveredFontFamily } = value;
|
||||
|
||||
let nexStoreAction: StoreActionType = StoreAction.NONE;
|
||||
let nextFontFamily: FontFamilyValues | undefined;
|
||||
let skipOnHoverRender = false;
|
||||
|
||||
if (currentItemFontFamily) {
|
||||
nextFontFamily = currentItemFontFamily;
|
||||
nexStoreAction = StoreAction.CAPTURE;
|
||||
} else if (currentHoveredFontFamily) {
|
||||
nextFontFamily = currentHoveredFontFamily;
|
||||
nexStoreAction = StoreAction.NONE;
|
||||
|
||||
const selectedTextElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
}).filter((element) => isTextElement(element));
|
||||
|
||||
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
|
||||
if (selectedTextElements.length > 200) {
|
||||
skipOnHoverRender = true;
|
||||
} else {
|
||||
let i = 0;
|
||||
let textLengthAccumulator = 0;
|
||||
|
||||
while (
|
||||
i < selectedTextElements.length &&
|
||||
textLengthAccumulator < 5000
|
||||
) {
|
||||
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
|
||||
textLengthAccumulator += textElement?.originalText.length || 0;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (textLengthAccumulator > 5000) {
|
||||
skipOnHoverRender = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemFontFamily: value,
|
||||
...nextAppState,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: nexStoreAction,
|
||||
};
|
||||
|
||||
if (nextFontFamily && !skipOnHoverRender) {
|
||||
const elementContainerMapping = new Map<
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawElement | null
|
||||
>();
|
||||
let uniqueGlyphs = new Set<string>();
|
||||
let skipFontFaceCheck = false;
|
||||
|
||||
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
||||
const fontFamily = Object.entries(FONT_FAMILY).find(
|
||||
([_, value]) => value === nextFontFamily,
|
||||
)?.[0];
|
||||
|
||||
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
|
||||
if (
|
||||
currentHoveredFontFamily &&
|
||||
fontFamily &&
|
||||
fontsCache.some((sig) => sig.startsWith(fontFamily))
|
||||
) {
|
||||
skipFontFaceCheck = true;
|
||||
}
|
||||
|
||||
// following causes re-render so make sure we changed the family
|
||||
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
|
||||
Object.assign(result, {
|
||||
elements: changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(oldElement) => {
|
||||
if (
|
||||
isTextElement(oldElement) &&
|
||||
(oldElement.fontFamily !== nextFontFamily ||
|
||||
currentItemFontFamily) // force update on selection
|
||||
) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: nextFontFamily,
|
||||
lineHeight: getLineHeight(nextFontFamily!),
|
||||
},
|
||||
);
|
||||
|
||||
const cachedContainer =
|
||||
cachedElements?.get(oldElement.containerId || "") || {};
|
||||
|
||||
const container = app.scene.getContainerElement(oldElement);
|
||||
|
||||
if (resetContainers && container && cachedContainer) {
|
||||
// reset the container back to it's cached version
|
||||
mutateElement(container, { ...cachedContainer }, false);
|
||||
}
|
||||
|
||||
if (!skipFontFaceCheck) {
|
||||
uniqueGlyphs = new Set([
|
||||
...uniqueGlyphs,
|
||||
...Array.from(newElement.originalText),
|
||||
]);
|
||||
}
|
||||
|
||||
elementContainerMapping.set(newElement, container);
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
return oldElement;
|
||||
},
|
||||
true,
|
||||
),
|
||||
});
|
||||
|
||||
// size is irrelevant, but necessary
|
||||
const fontString = `10px ${getFontFamilyString({
|
||||
fontFamily: nextFontFamily,
|
||||
})}`;
|
||||
const glyphs = Array.from(uniqueGlyphs.values()).join();
|
||||
|
||||
if (
|
||||
skipFontFaceCheck ||
|
||||
window.document.fonts.check(fontString, glyphs)
|
||||
) {
|
||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// trigger synchronous redraw
|
||||
redrawTextBoundingBox(
|
||||
element,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||
const latestElement = app.scene.getElement(element.id);
|
||||
const latestContainer = container
|
||||
? app.scene.getElement(container.id)
|
||||
: null;
|
||||
|
||||
if (latestElement) {
|
||||
// trigger async redraw
|
||||
redrawTextBoundingBox(
|
||||
latestElement as ExcalidrawTextElement,
|
||||
latestContainer,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// trigger update once we've mutated all the elements, which also updates our cache
|
||||
app.fonts.onLoaded(fontFaces);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const options: {
|
||||
value: FontFamilyValues;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
testId: string;
|
||||
}[] = [
|
||||
{
|
||||
value: FONT_FAMILY.Virgil,
|
||||
text: t("labels.handDrawn"),
|
||||
icon: FreedrawIcon,
|
||||
testId: "font-family-virgil",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Helvetica,
|
||||
text: t("labels.normal"),
|
||||
icon: FontFamilyNormalIcon,
|
||||
testId: "font-family-normal",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Cascadia,
|
||||
text: t("labels.code"),
|
||||
icon: FontFamilyCodeIcon,
|
||||
testId: "font-family-code",
|
||||
},
|
||||
];
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
|
||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
|
||||
const isUnmounted = useRef(true);
|
||||
|
||||
const selectedFontFamily = useMemo(() => {
|
||||
const getFontFamily = (
|
||||
elementsArray: readonly ExcalidrawElement[],
|
||||
elementsMap: Map<string, ExcalidrawElement>,
|
||||
) =>
|
||||
getFormValue(
|
||||
elementsArray,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontFamily;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) =>
|
||||
isTextElement(element) ||
|
||||
getBoundTextElement(element, elementsMap) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
);
|
||||
|
||||
// popup opened, use cached elements
|
||||
if (
|
||||
batchedData.openPopup === "fontFamily" &&
|
||||
appState.openPopup === "fontFamily"
|
||||
) {
|
||||
return getFontFamily(
|
||||
Array.from(cachedElementsRef.current?.values() ?? []),
|
||||
cachedElementsRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
// popup closed, use all elements
|
||||
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
|
||||
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
|
||||
}
|
||||
|
||||
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
|
||||
return prevSelectedFontFamilyRef.current;
|
||||
}, [batchedData.openPopup, appState, elements, app.scene]);
|
||||
|
||||
useEffect(() => {
|
||||
prevSelectedFontFamilyRef.current = selectedFontFamily;
|
||||
}, [selectedFontFamily]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(batchedData).length) {
|
||||
updateData(batchedData);
|
||||
// reset the data after we've used the data
|
||||
setBatchedData({});
|
||||
}
|
||||
// call update only on internal state changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchedData]);
|
||||
|
||||
useEffect(() => {
|
||||
isUnmounted.current = false;
|
||||
|
||||
return () => {
|
||||
isUnmounted.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
<FontPicker
|
||||
isOpened={appState.openPopup === "fontFamily"}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||
onSelect={(fontFamily) => {
|
||||
setBatchedData({
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
currentItemFontFamily: fontFamily,
|
||||
});
|
||||
|
||||
// defensive clear so immediate close won't abuse the cached elements
|
||||
cachedElementsRef.current.clear();
|
||||
}}
|
||||
onHover={(fontFamily) => {
|
||||
setBatchedData({
|
||||
currentHoveredFontFamily: fontFamily,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetContainers: true,
|
||||
});
|
||||
}}
|
||||
onLeave={() => {
|
||||
setBatchedData({
|
||||
currentHoveredFontFamily: null,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetAll: true,
|
||||
});
|
||||
}}
|
||||
onPopupChange={(open) => {
|
||||
if (open) {
|
||||
// open, populate the cache from scratch
|
||||
cachedElementsRef.current.clear();
|
||||
|
||||
const { editingElement } = appState;
|
||||
|
||||
if (editingElement?.type === "text") {
|
||||
// retrieve the latest version from the scene, as `editingElement` isn't mutated
|
||||
const latestEditingElement = app.scene.getElement(
|
||||
editingElement.id,
|
||||
);
|
||||
|
||||
// inside the wysiwyg editor
|
||||
cachedElementsRef.current.set(
|
||||
editingElement.id,
|
||||
newElementWith(
|
||||
latestEditingElement || editingElement,
|
||||
{},
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const selectedElements = getSelectedElements(
|
||||
elements,
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
|
||||
for (const element of selectedElements) {
|
||||
cachedElementsRef.current.set(
|
||||
element.id,
|
||||
newElementWith(element, {}, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontFamily;
|
||||
|
||||
setBatchedData({
|
||||
openPopup: "fontFamily",
|
||||
});
|
||||
} else {
|
||||
// close, use the cache and clear it afterwards
|
||||
const data = {
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetAll: true,
|
||||
} as ChangeFontFamilyData;
|
||||
|
||||
if (isUnmounted.current) {
|
||||
// in case the component was unmounted by the parent, trigger the update directly
|
||||
updateData({ ...batchedData, ...data });
|
||||
} else {
|
||||
setBatchedData(data);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) =>
|
||||
isTextElement(element) ||
|
||||
getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
|
||||
cachedElementsRef.current.clear();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
|
|
|
@ -12,10 +12,7 @@ import {
|
|||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
|
@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene";
|
|||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { paintIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
|
@ -122,7 +120,7 @@ export const actionPasteStyles = register({
|
|||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
getLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue