Compare commits

...

7 Commits

Author SHA1 Message Date
are b51f6d178c fix: checkbox position misaligned caused by margin 2025-01-31 00:38:46 +01:00
Shalini 84bab403ff Fix: issue #8818 Xiaolai font has been set as a fallback for Excalifont (#9055)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-01-30 13:41:41 +00:00
Are 61e0bb83d0 feat: improve library sidebar performance (#9060)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-30 14:41:08 +01:00
Saikat Das bd1590fc74 feat: implement custom Range component for opacity control (#9009)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-29 21:46:40 +00:00
Marcel Mraz d29c3db7f6 fix: fonts not loading on export (again) (#9064) 2025-01-29 22:24:26 +01:00
Marcel Mraz a58822c1c1 fix: merge server-side fonts with liberation sans (#9052) 2025-01-29 22:04:49 +01:00
David Luzar a3e1619635 fix: hyperlinks html entities (#9063) 2025-01-29 19:02:54 +01:00
19 changed files with 237 additions and 140 deletions
@@ -121,6 +121,7 @@ import {
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
import { Range } from "../components/Range";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -630,25 +631,12 @@ export const actionChangeOpacity = register({
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<label className="control-label">
{t("labels.opacity")}
<input
type="range"
min="0"
max="100"
step="10"
onChange={(event) => updateData(+event.target.value)}
value={
getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
) ?? undefined
}
/>
</label>
<Range
updateData={updateData}
elements={elements}
appState={appState}
testId="opacity"
/>
),
});
+44 -20
View File
@@ -1,4 +1,10 @@
import React, { useState, useCallback, useMemo, useRef } from "react";
import React, {
useState,
useCallback,
useMemo,
useRef,
useEffect,
} from "react";
import type Library from "../data/library";
import {
distributeLibraryItemsOnSquareGrid,
@@ -150,27 +156,40 @@ const usePendingElementsMemo = (
appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[],
) => {
const create = () =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const val = useRef(create());
const create = useCallback(
(appState: UIAppState, elements: readonly NonDeletedExcalidrawElement[]) =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
[],
);
const val = useRef(create(appState, elements));
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);
if (
!isShallowEqual(
appState.selectedElementIds,
prevAppState.current.selectedElementIds,
) ||
!isShallowEqual(elements, prevElements.current)
) {
val.current = create();
prevAppState.current = appState;
prevElements.current = elements;
}
return val.current;
const update = useCallback(() => {
if (
!isShallowEqual(
appState.selectedElementIds,
prevAppState.current.selectedElementIds,
) ||
!isShallowEqual(elements, prevElements.current)
) {
val.current = create(appState, elements);
prevAppState.current = appState;
prevElements.current = elements;
}
}, [create, appState, elements]);
return useMemo(
() => ({
update,
value: val.current,
}),
[update, val],
);
};
/**
@@ -181,6 +200,7 @@ export const LibraryMenu = () => {
const { library, id, onInsertElements } = useApp();
const appProps = useAppProps();
const appState = useUIAppState();
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
@@ -203,9 +223,13 @@ export const LibraryMenu = () => {
});
}, [setAppState]);
useEffect(() => {
return app.onPointerUpEmitter.on(() => pendingElements.update());
}, [app, pendingElements]);
return (
<LibraryMenuContent
pendingElements={pendingElements}
pendingElements={pendingElements.value}
onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems}
setAppState={setAppState}
@@ -63,27 +63,6 @@
max-width: 100%;
}
.library-unit__checkbox-container,
.library-unit__checkbox-container:hover,
.library-unit__checkbox-container:active {
align-items: center;
background: none;
border: none;
color: var(--icon-fill-color);
display: flex;
justify-content: center;
margin: 0;
padding: 0.5rem;
position: absolute;
left: 2rem;
bottom: 2rem;
cursor: pointer;
input {
cursor: pointer;
}
}
.library-unit__checkbox {
position: absolute;
top: 0.125rem;
@@ -91,7 +70,7 @@
margin: 0;
.Checkbox-box {
margin: 0;
margin: 0 !important;
width: 1rem;
height: 1rem;
border-radius: 4px;
+59
View File
@@ -0,0 +1,59 @@
@import "../css/variables.module.scss";
.excalidraw {
--Range-track-background: var(--button-bg);
--Range-track-background-active: var(--color-primary);
--Range-thumb-background: var(--color-on-surface);
--Range-legend-color: var(--text-primary-color);
.range-wrapper {
position: relative;
padding-top: 10px;
padding-bottom: 30px;
}
.range-input {
width: 100%;
height: 4px;
-webkit-appearance: none;
background: var(--Range-track-background);
border-radius: 2px;
outline: none;
}
.range-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: var(--Range-thumb-background);
border-radius: 50%;
cursor: pointer;
border: none;
}
.range-input::-moz-range-thumb {
width: 20px;
height: 20px;
background: var(--Range-thumb-background);
border-radius: 50%;
cursor: pointer;
border: none;
}
.value-bubble {
position: absolute;
bottom: 0;
transform: translateX(-50%);
font-size: 12px;
color: var(--Range-legend-color);
}
.zero-label {
position: absolute;
bottom: 0;
left: 4px;
font-size: 12px;
color: var(--Range-legend-color);
}
}
+65
View File
@@ -0,0 +1,65 @@
import React, { useEffect } from "react";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n";
import "./Range.scss";
export type RangeProps = {
updateData: (value: number) => void;
appState: any;
elements: any;
testId?: string;
};
export const Range = ({
updateData,
appState,
elements,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const value = getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
);
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb
const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
}
}, [value]);
return (
<label className="control-label">
{t("labels.opacity")}
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
onChange={(event) => {
updateData(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
</div>
<div className="zero-label">0</div>
</div>
</label>
);
};
@@ -1,6 +1,6 @@
.excalidraw {
.excalifont {
font-family: "Excalifont";
font-family: "Excalifont", "Xiaolai";
}
// WelcomeSreen common
+3
View File
@@ -32,6 +32,7 @@
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--button-bg: var(--color-surface-mid);
--button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high);
--button-active-border: var(--color-brand-active);
@@ -171,6 +172,8 @@
--button-destructive-bg-color: #5a0000;
--button-destructive-color: #{$oc-red-3};
--button-bg: var(--color-surface-high);
--button-gray-1: #363636;
--button-gray-2: #272727;
--button-gray-3: #222;
+2 -2
View File
@@ -25,7 +25,7 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("&lt;test&gt;");
expect(normalizeLink("test&")).toBe("test&amp;");
expect(normalizeLink("<test>")).toBe("<test>");
expect(normalizeLink("test&")).toBe("test&");
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { sanitizeHTMLAttribute } from "../utils";
import { escapeDoubleQuotes } from "../utils";
export const normalizeLink = (link: string) => {
link = link.trim();
if (!link) {
return link;
}
return sanitizeUrl(sanitizeHTMLAttribute(link));
return sanitizeUrl(escapeDoubleQuotes(link));
};
export const isLocalLink = (link: string | null) => {
+4 -8
View File
@@ -1,11 +1,7 @@
import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types";
import {
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping";
@@ -212,7 +208,7 @@ export const getEmbedLink = (
// Note that we don't attempt to parse the username as it can consist of
// non-latin1 characters, and the username in the url can be set to anything
// without affecting the embed.
const safeURL = sanitizeHTMLAttribute(
const safeURL = escapeDoubleQuotes(
`https://twitter.com/x/status/${postId}`,
);
@@ -231,7 +227,7 @@ export const getEmbedLink = (
if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = sanitizeHTMLAttribute(
const safeURL = escapeDoubleQuotes(
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
);
const ret: IframeDataWithSandbox = {
@@ -249,7 +245,7 @@ export const getEmbedLink = (
if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute(
const safeURL = escapeDoubleQuotes(
`https://gist.github.com/${user}/${gistId}`,
);
const ret: IframeDataWithSandbox = {
+9 -35
View File
@@ -3,7 +3,6 @@ import {
FONT_FAMILY_FALLBACKS,
CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT,
isSafari,
getFontFamilyFallbacks,
} from "../constants";
import { isTextElement } from "../element";
@@ -137,50 +136,28 @@ export class Fonts {
/**
* Load font faces for a given scene and trigger scene 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.
*
* For Safari we make sure to check against each loaded font face
* with the unique characters per family in the scene,
* otherwise fonts might remain unloaded.
*/
public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFamilies();
const charsPerFamily = isSafari
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
: undefined;
const charsPerFamily = Fonts.getCharsPerFamily(
this.scene.getNonDeletedElements(),
);
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
};
/**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*
* For Safari we make sure to check against each loaded font face,
* with the unique characters per family in the elements
* otherwise fonts might remain unloaded.
*/
public static loadElementsFonts = async (
elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => {
const fontFamilies = Fonts.getUniqueFamilies(elements);
const charsPerFamily = isSafari
? Fonts.getCharsPerFamily(elements)
: undefined;
const charsPerFamily = Fonts.getCharsPerFamily(elements);
return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
};
/**
* Load all registered font faces.
*/
public static loadAllFonts = async (): Promise<FontFace[]> => {
const allFamilies = Fonts.getAllFamilies();
return Fonts.loadFontFaces(allFamilies);
};
/**
* Generate CSS @font-face declarations for the given elements.
*/
@@ -223,7 +200,7 @@ export class Fonts {
private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>,
charsPerFamily: Record<number, Set<string>>,
) {
// add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces, metadata } of Fonts.registered.values()) {
@@ -248,7 +225,7 @@ export class Fonts {
private static *fontFacesLoader(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>,
charsPerFamily: Record<number, Set<string>>,
): Generator<Promise<void | readonly [number, FontFace[]]>> {
for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({
@@ -256,12 +233,9 @@ export class Fonts {
fontSize: 16,
});
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
const text =
isSafari && charsPerFamily
? Fonts.getCharacters(charsPerFamily, fontFamily)
: "";
// WARN: without "text" param it does not have to mean that all font faces are loaded as it could be just one irrelevant font face!
// instead, we are always checking chars used in the family, so that no required font faces remain unloaded
const text = Fonts.getCharacters(charsPerFamily, fontFamily);
if (!window.document.fonts.check(font, text)) {
yield promiseTry(async () => {
@@ -50,7 +50,7 @@ describe("actionStyles", () => {
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
fireEvent.change(screen.getByTestId("opacity"), {
target: { value: "60" },
});
@@ -338,7 +338,7 @@ describe("contextMenu element", () => {
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
fireEvent.change(screen.getByTestId("opacity"), {
target: { value: "60" },
});
+1 -7
View File
@@ -1,4 +1,4 @@
import { isTransparent, sanitizeHTMLAttribute } from "../utils";
import { isTransparent } from "../utils";
describe("Test isTransparent", () => {
it("should return true when color is rgb transparent", () => {
@@ -11,9 +11,3 @@ describe("Test isTransparent", () => {
expect(isTransparent("#ced4da")).toEqual(false);
});
});
describe("sanitizeHTMLAttribute()", () => {
it("should escape HTML attribute special characters & not double escape", () => {
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&amp;&quot;&#39;&gt;&lt;");
});
});
+2
View File
@@ -681,6 +681,8 @@ export type AppClassProperties = {
getEditorUIOffsets: App["getEditorUIOffsets"];
visibleElements: App["visibleElements"];
excalidrawContainerValue: App["excalidrawContainerValue"];
onPointerUpEmitter: App["onPointerUpEmitter"];
};
export type PointerDownState = Readonly<{
+6 -11
View File
@@ -1226,15 +1226,10 @@ export class PromisePool<T> {
}
}
export const sanitizeHTMLAttribute = (html: string) => {
return (
html
// note, if we're not doing stupid things, escaping " is enough,
// but we might end up doing stupid things
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/>/g, "&gt;")
.replace(/</g, "&lt;")
);
/**
* use when you need to render unsafe string as HTML attribute, but MAKE SURE
* the attribute is double-quoted when constructing the HTML string
*/
export const escapeDoubleQuotes = (str: string) => {
return str.replace(/"/g, "&quot;");
};
Binary file not shown.
Binary file not shown.
+29 -11
View File
@@ -123,6 +123,17 @@ module.exports.woff2ServerPlugin = (options = {}) => {
"./assets/NotoEmoji-Regular-2048.ttf",
);
const liberationPath = path.resolve(
__dirname,
"./assets/LiberationSans-Regular.ttf",
);
// need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
const liberationPath_2048 = path.resolve(
__dirname,
"./assets/LiberationSans-Regular-2048.ttf",
);
const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
type: "ttf",
});
@@ -130,6 +141,10 @@ module.exports.woff2ServerPlugin = (options = {}) => {
type: "ttf",
});
const liberationFont = Font.create(fs.readFileSync(liberationPath), {
type: "ttf",
});
const sortedFonts = Array.from(fonts.entries()).sort(
([family1], [family2]) => (family1 > family2 ? 1 : -1),
);
@@ -141,13 +156,6 @@ module.exports.woff2ServerPlugin = (options = {}) => {
continue;
}
const fallbackFontsPaths = [];
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
if (shouldIncludeXiaolaiFallback) {
fallbackFontsPaths.push(xiaolaiPath);
}
const baseFont = Regular[0];
const tempPaths = Regular.map((_, index) =>
path.resolve(outputDir, `temp_${family}_${index}.ttf`),
@@ -165,10 +173,18 @@ module.exports.woff2ServerPlugin = (options = {}) => {
const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
const fallbackFontsPaths = [];
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
if (shouldIncludeXiaolaiFallback) {
fallbackFontsPaths.push(xiaolaiPath);
}
// add liberation as fallback to all fonts, so that unknown characters are rendered similarly to how browser renders them (Helvetica, Arial, etc.)
if (baseFont.data.head.unitsPerEm === 2048) {
fallbackFontsPaths.push(emojiPath_2048);
fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048);
} else {
fallbackFontsPaths.push(emojiPath);
fallbackFontsPaths.push(emojiPath, liberationPath);
}
// drop Vertical related metrics, otherwise it does not allow us to merge the fonts
@@ -196,10 +212,12 @@ module.exports.woff2ServerPlugin = (options = {}) => {
const base = baseFont.data.name[field];
const xiaolai = xiaolaiFont.data.name[field];
const emoji = emojiFont.data.name[field];
const liberation = liberationFont.data.name[field];
// liberation font
return shouldIncludeXiaolaiFallback
? `${base} & ${xiaolai} & ${emoji}`
: `${base} & ${emoji}`;
? `${base} & ${xiaolai} & ${emoji} & ${liberation}`
: `${base} & ${emoji} & ${liberation}`;
};
mergedFont.set({