Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle
3fdef201b0 fix: youtube embed timestamp parsing 2024-10-14 11:02:39 +02:00
345 changed files with 1321 additions and 19108 deletions

View File

@@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img
src={require("@site/static/img/welcome-screen-overview.png").default}
alt="Excalidraw logo: Sketch hand-drawn like diagrams."
alt="Excalidraw logo: Sketch handrawn like diagrams."
/>
### Center

View File

@@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
| Font Family | Description |
| ----------- | ---------------------- |
| `Virgil` | The `Hand-drawn` font |
| `Virgil` | The `handwritten` font |
| `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font |

View File

@@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts
# copied assets
public/**/*.woff2
public/*.woff2

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",

View File

@@ -1,2 +1,2 @@
# copied assets
public/**/*.woff2
public/*.woff2

View File

@@ -13,7 +13,7 @@
},
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002"

View File

@@ -133,7 +133,7 @@
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/fonts.css"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>

View File

@@ -26,17 +26,17 @@
"node": ">=18.0.0"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2",
"vite-plugin-html": "3.2.2"
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
@@ -49,8 +49,5 @@
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
}
}

View File

@@ -5,7 +5,6 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables
@@ -26,8 +25,8 @@ export default defineConfig({
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
const family = chunkInfo.name.split("-")[0];
return `fonts/${family}/[name][extname]`;
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
@@ -53,13 +52,6 @@ export default defineConfig({
assetsInlineLimit: 0,
},
plugins: [
Sitemap({
hostname: "https://excalidraw.com",
outDir: "build",
changefreq: "monthly",
// its static in public folder
generateRobotsTxt: false,
}),
woff2BrowserPlugin(),
react(),
checker({
@@ -83,26 +75,17 @@ export default defineConfig({
},
workbox: {
// don't precache fonts, locales and separate chunks
globIgnores: [
"fonts.css",
"**/locales/**",
"service-worker.js",
"**/*.chunk-*.js",
],
// Don't push fonts, locales and wasm to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
runtimeCaching: [
{
urlPattern: new RegExp(".+.woff2"),
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
},
cacheableResponse: {
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
statuses: [0, 200],
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
@@ -128,10 +111,10 @@ export default defineConfig({
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
urlPattern: new RegExp(".wasm-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days

View File

@@ -15,8 +15,6 @@ Please add the latest change on the top under the correct section.
### Features
- Added hand-drawn font for Chinese, Japanese and Korean (CJK) as a fallback for Excalifont. Improved overal text wrapping algorithm, not only accounting for CJK, but covering various edge cases with white spaces and text-align center/right. Added support for multi-codepoint emojis wrapping. Offloaded SVG export to Web Workers, with an automatic fallback to the main thread if not supported or not desired.[#8530](https://github.com/excalidraw/excalidraw/pull/8530)
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)

View File

@@ -147,32 +147,14 @@ export const actionCopyAsSvg = register({
name: app.getName(),
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return {
appState: {
toast: {
message: t("toast.copyToClipboardAsSvg", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,

View File

@@ -1,55 +0,0 @@
import { register } from "./register";
import { cropIcon } from "../components/icons";
import { StoreAction } from "../store";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { isImageElement } from "../element/typeChecks";
import type { ExcalidrawImageElement } from "../element/types";
export const actionToggleCropEditor = register({
name: "cropEditor",
label: "helpDialog.cropStart",
icon: cropIcon,
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["image", "crop"],
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawImageElement;
return {
appState: {
...appState,
isCropping: false,
croppingElementId: selectedElement.id,
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.croppingElementId &&
selectedElements.length === 1 &&
isImageElement(selectedElements[0])
) {
return true;
}
return false;
},
PanelComponent: ({ appState, updateData, app }) => {
const label = t("helpDialog.cropStart");
return (
<ToolButton
type="button"
icon={cropIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});

View File

@@ -5,7 +5,7 @@ import { t } from "../i18n";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppClassProperties, AppState } from "../types";
import { KEYS, matchKey } from "../keys";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types";
@@ -63,7 +63,9 @@ export const createUndoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
@@ -102,8 +104,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,

View File

@@ -88,5 +88,3 @@ export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
export { actionToggleCropEditor } from "./actionCropEditor";

View File

@@ -23,6 +23,7 @@ export type ShortcutName =
| "sendToBack"
| "bringToFront"
| "copyAsPng"
| "copyAsSvg"
| "group"
| "ungroup"
| "gridMode"
@@ -87,6 +88,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
: getShortcutKey("CtrlOrCmd+Shift+]"),
],
copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],

View File

@@ -10,6 +10,7 @@ import type {
BinaryFiles,
UIAppState,
} from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store";
export type ActionSource =
@@ -23,7 +24,10 @@ export type ActionSource =
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: Partial<AppState> | null;
appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
storeAction: StoreActionType;
replaceFiles?: boolean;
@@ -134,8 +138,7 @@ export type ActionName =
| "commandPalette"
| "autoResize"
| "elementStats"
| "searchMenu"
| "cropEditor";
| "searchMenu";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];

View File

@@ -116,8 +116,6 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [],
};
};
@@ -239,8 +237,6 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
});

View File

@@ -17,16 +17,13 @@ import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isImageElement,
isTextElement,
} from "./element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./element/types";
@@ -629,18 +626,6 @@ export class AppStateChange implements Change<AppState> {
);
break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId":
const editingGroupId = nextAppState[key];
@@ -771,7 +756,6 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds,
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
...standaloneProps
} = delta as ObservedAppState;
@@ -795,10 +779,7 @@ export class AppStateChange implements Change<AppState> {
}
}
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
ElementUpdate<Ordered<T>>,
"seed"
>;
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
/**
* Elements change is a low level primitive to capture a change between two sets of elements.
@@ -1235,18 +1216,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
});
}
if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;

View File

@@ -26,7 +26,6 @@ import { trackEvent } from "../analytics";
import {
hasBoundTextElement,
isElbowArrow,
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
@@ -128,11 +127,6 @@ export const SelectedShapeActions = ({
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
return (
<div className="panelColumn">
<div>
@@ -251,7 +245,6 @@ export const SelectedShapeActions = ({
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</div>
</fieldset>

View File

@@ -35,7 +35,6 @@ import {
actionToggleElementLock,
actionToggleLinearEditor,
actionToggleObjectsSnapMode,
actionToggleCropEditor,
} from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
@@ -446,19 +445,7 @@ import {
} from "../element/flowchart";
import { searchItemInFocusAtom } from "./SearchMenu";
import type { LocalPoint, Radians } from "../../math";
import {
clamp,
pointFrom,
pointDistance,
vector,
pointRotateRads,
vectorScale,
vectorFromPoint,
vectorSubtract,
vectorDot,
vectorNormalize,
} from "../../math";
import { cropElement } from "../element/cropElement";
import { pointFrom, pointDistance, vector } from "../../math";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -602,7 +589,6 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null;
lastPointerMoveEvent: PointerEvent | null = null;
lastPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 };
animationFrameHandler = new AnimationFrameHandler();
@@ -750,7 +736,7 @@ class App extends React.Component<AppProps, AppState> {
id: this.id,
};
this.fonts = new Fonts(this.scene);
this.fonts = new Fonts({ scene: this.scene });
this.history = new History();
this.actionManager.registerAll(actions);
@@ -2164,12 +2150,11 @@ class App extends React.Component<AppProps, AppState> {
editingTextElement = null;
}
this.setState((prevAppState) => {
const actionAppState = actionResult.appState || {};
return {
...prevAppState,
...actionAppState,
this.setState((state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
@@ -2180,7 +2165,7 @@ class App extends React.Component<AppProps, AppState> {
theme,
name,
errorMessage,
};
});
});
didUpdate = true;
@@ -2485,7 +2470,7 @@ class App extends React.Component<AppProps, AppState> {
this.renderer.destroy();
this.scene.destroy();
this.scene = new Scene();
this.fonts = new Fonts(this.scene);
this.fonts = new Fonts({ scene: this.scene });
this.renderer = new Renderer(this.scene);
this.files = {};
this.imageCache.clear();
@@ -3938,28 +3923,6 @@ class App extends React.Component<AppProps, AppState> {
}
if (!isInputLike(event.target)) {
if (
(event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
this.state.croppingElementId
) {
this.finishImageCropping();
return;
}
const selectedElements = getSelectedElements(
this.scene.getNonDeletedElementsMap(),
this.state,
);
if (
selectedElements.length === 1 &&
isImageElement(selectedElements[0]) &&
event.key === KEYS.ENTER
) {
this.startImageCropping(selectedElements[0]);
return;
}
if (
event.key === KEYS.ESCAPE &&
this.flowChartCreator.isCreatingChart
@@ -4774,13 +4737,10 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement();
}
flushSync(() => {
this.setState({
newElement: null,
editingTextElement: null,
});
this.setState({
newElement: null,
editingTextElement: null,
});
if (this.state.activeTool.locked) {
setCursorForShape(this.interactiveCanvas, this.state);
}
@@ -4947,7 +4907,7 @@ class App extends React.Component<AppProps, AppState> {
const selectionShape = getSelectionBoxShape(
element,
this.scene.getNonDeletedElementsMap(),
isImageElement(element) ? 0 : this.getElementHitThreshold(),
this.getElementHitThreshold(),
);
return isPointInShape(pointFrom(x, y), selectionShape);
@@ -5176,22 +5136,6 @@ class App extends React.Component<AppProps, AppState> {
}
};
private startImageCropping = (image: ExcalidrawImageElement) => {
this.store.shouldCaptureIncrement();
this.setState({
croppingElementId: image.id,
});
};
private finishImageCropping = () => {
if (this.state.croppingElementId) {
this.store.shouldCaptureIncrement();
this.setState({
croppingElementId: null,
});
}
};
private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>,
) => {
@@ -5223,11 +5167,6 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
this.startImageCropping(selectedElements[0]);
return;
}
resetCursor(this.interactiveCanvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
@@ -6797,24 +6736,11 @@ class App extends React.Component<AppProps, AppState> {
this.device,
);
if (elementWithTransformHandleType != null) {
if (
elementWithTransformHandleType.transformHandleType === "rotation"
) {
this.setState({
resizingElement: elementWithTransformHandleType.element,
});
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
} else if (this.state.croppingElementId) {
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
} else {
this.setState({
resizingElement: elementWithTransformHandleType.element,
});
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
}
this.setState({
resizingElement: elementWithTransformHandleType.element,
});
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
}
} else if (selectedElements.length > 1) {
pointerDownState.resize.handleType = getTransformHandleTypeFromCoords(
@@ -6881,13 +6807,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y,
);
if (
this.state.croppingElementId &&
pointerDownState.hit.element?.id !== this.state.croppingElementId
) {
this.finishImageCropping();
}
if (pointerDownState.hit.element) {
// Early return if pointer is hitting link icon
const hitLinkElement = this.getElementLinkAtPosition(
@@ -7689,11 +7608,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState,
) {
return withBatchedUpdatesThrottled((event: PointerEvent) => {
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin;
this.lastPointerMoveCoords = pointerCoords;
// We need to initialize dragOffsetXY only after we've updated
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
// event handler should hopefully ensure we're already working with
@@ -7716,6 +7630,8 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (isEraserActive(this.state)) {
this.handleEraser(event, pointerDownState, pointerCoords);
return;
@@ -7752,9 +7668,6 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.resize.isResizing) {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
if (this.maybeHandleCrop(pointerDownState, event)) {
return true;
}
if (this.maybeHandleResize(pointerDownState, event)) {
return true;
}
@@ -7928,96 +7841,6 @@ class App extends React.Component<AppProps, AppState> {
}
}
// #region move crop region
if (this.state.croppingElementId) {
const croppingElement = this.scene
.getNonDeletedElementsMap()
.get(this.state.croppingElementId);
if (
croppingElement &&
isImageElement(croppingElement) &&
croppingElement.crop !== null &&
pointerDownState.hit.element === croppingElement
) {
const crop = croppingElement.crop;
const image =
isInitializedImageElement(croppingElement) &&
this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) {
const instantDragOffset = vectorScale(
vector(
pointerCoords.x - lastPointerCoords.x,
pointerCoords.y - lastPointerCoords.y,
),
Math.max(this.state.zoom.value, 2),
);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
croppingElement,
elementsMap,
);
const topLeft = vectorFromPoint(
pointRotateRads(
pointFrom(x1, y1),
pointFrom(cx, cy),
croppingElement.angle,
),
);
const topRight = vectorFromPoint(
pointRotateRads(
pointFrom(x2, y1),
pointFrom(cx, cy),
croppingElement.angle,
),
);
const bottomLeft = vectorFromPoint(
pointRotateRads(
pointFrom(x1, y2),
pointFrom(cx, cy),
croppingElement.angle,
),
);
const topEdge = vectorNormalize(
vectorSubtract(topRight, topLeft),
);
const leftEdge = vectorNormalize(
vectorSubtract(bottomLeft, topLeft),
);
// project instantDrafOffset onto leftEdge and topEdge to decompose
const offsetVector = vector(
vectorDot(instantDragOffset, topEdge),
vectorDot(instantDragOffset, leftEdge),
);
const nextCrop = {
...crop,
x: clamp(
crop.x -
offsetVector[0] * Math.sign(croppingElement.scale[0]),
0,
image.naturalWidth - crop.width,
),
y: clamp(
crop.y -
offsetVector[1] * Math.sign(croppingElement.scale[1]),
0,
image.naturalHeight - crop.height,
),
};
mutateElement(croppingElement, {
crop: nextCrop,
});
return;
}
}
}
// Snap cache *must* be synchronously popuplated before initial drag,
// otherwise the first drag even will not snap, causing a jump before
// it snaps to its position if previously snapped already.
@@ -8151,7 +7974,6 @@ class App extends React.Component<AppProps, AppState> {
this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
}
return;
}
}
@@ -8400,18 +8222,15 @@ class App extends React.Component<AppProps, AppState> {
const {
newElement,
resizingElement,
croppingElementId,
multiElement,
activeTool,
isResizing,
isRotating,
isCropping,
} = this.state;
this.setState((prevState) => ({
isResizing: false,
isRotating: false,
isCropping: false,
resizingElement: null,
selectionElement: null,
frameToHighlight: null,
@@ -8421,8 +8240,6 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null,
}));
this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null);
@@ -8905,20 +8722,6 @@ class App extends React.Component<AppProps, AppState> {
}
}
// click outside the cropping region to exit
if (
// not in the cropping mode at all
!croppingElementId ||
// in the cropping mode
(croppingElementId &&
// not cropping and no hit element
((!hitElement && !isCropping) ||
// hitting something else
(hitElement && hitElement.id !== croppingElementId)))
) {
this.finishImageCropping();
}
const pointerStart = this.lastPointerDownEvent;
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
@@ -9174,12 +8977,7 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement();
}
if (
pointerDownState.drag.hasOccurred ||
isResizing ||
isRotating ||
isCropping
) {
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
// We only allow binding via linear elements, specifically via dragging
// the endpoints ("start" or "end").
const linearElements = this.scene
@@ -9393,7 +9191,7 @@ class App extends React.Component<AppProps, AppState> {
/**
* inserts image into elements array and rerenders
*/
insertImageElement = async (
private insertImageElement = async (
imageElement: ExcalidrawImageElement,
imageFile: File,
showCursorImagePreview?: boolean,
@@ -9546,7 +9344,7 @@ class App extends React.Component<AppProps, AppState> {
}
};
initializeImageDimensions = (
private initializeImageDimensions = (
imageElement: ExcalidrawImageElement,
forceNaturalSize = false,
) => {
@@ -9594,13 +9392,7 @@ class App extends React.Component<AppProps, AppState> {
const x = imageElement.x + imageElement.width / 2 - width / 2;
const y = imageElement.y + imageElement.height / 2 - height / 2;
mutateElement(imageElement, {
x,
y,
width,
height,
crop: null,
});
mutateElement(imageElement, { x, y, width, height });
}
};
@@ -10139,83 +9931,6 @@ class App extends React.Component<AppProps, AppState> {
}
};
private maybeHandleCrop = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,
): boolean => {
// to crop, we must already be in the cropping mode, where croppingElement has been set
if (!this.state.croppingElementId) {
return false;
}
const transformHandleType = pointerDownState.resize.handleType;
const pointerCoords = pointerDownState.lastCoords;
const [x, y] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
this.getEffectiveGridSize(),
);
const croppingElement = this.scene
.getNonDeletedElementsMap()
.get(this.state.croppingElementId);
if (
transformHandleType &&
croppingElement &&
isImageElement(croppingElement)
) {
const croppingAtStateStart = pointerDownState.originalElements.get(
croppingElement.id,
);
const image =
isInitializedImageElement(croppingElement) &&
this.imageCache.get(croppingElement.fileId)?.image;
if (
croppingAtStateStart &&
isImageElement(croppingAtStateStart) &&
image &&
!(image instanceof Promise)
) {
mutateElement(
croppingElement,
cropElement(
croppingElement,
transformHandleType,
image.naturalWidth,
image.naturalHeight,
x,
y,
event.shiftKey
? croppingAtStateStart.width / croppingAtStateStart.height
: undefined,
),
);
updateBoundElements(
croppingElement,
this.scene.getNonDeletedElementsMap(),
{
oldSize: {
width: croppingElement.width,
height: croppingElement.height,
},
},
);
this.setState({
isCropping: transformHandleType && transformHandleType !== "rotation",
});
}
return true;
}
return false;
};
private maybeHandleResize = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,
@@ -10232,9 +9947,7 @@ class App extends React.Component<AppProps, AppState> {
// Frames cannot be rotated.
(selectedFrames.length > 0 && transformHandleType === "rotation") ||
// Elbow arrows cannot be transformed (resized or rotated).
(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) ||
// Do not resize when in crop mode
this.state.croppingElementId
(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
) {
return false;
}
@@ -10409,8 +10122,6 @@ class App extends React.Component<AppProps, AppState> {
actionSelectAllElementsInFrame,
actionRemoveAllElementsFromFrame,
CONTEXT_MENU_SEPARATOR,
actionToggleCropEditor,
CONTEXT_MENU_SEPARATOR,
...options,
CONTEXT_MENU_SEPARATOR,
actionCopyStyles,

View File

@@ -279,7 +279,6 @@ function CommandPaletteInner({
actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink,
].map((action: Action) =>
actionToCommand(

View File

@@ -21,7 +21,7 @@ export const DEFAULT_FONTS = [
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-hand-drawn",
testId: "font-family-handrawn",
},
{
value: FONT_FAMILY.Nunito,

View File

@@ -21,7 +21,6 @@ import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types";
import { FontFamilyNormalIcon } from "../icons";
export interface FontDescriptor {
value: number;
@@ -63,14 +62,12 @@ export const FontPickerList = React.memo(
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
)
.map(([familyId, { metadata, fontFaces }]) => {
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fonts }]) => {
const fontDescriptor = {
value: familyId,
icon: metadata.icon ?? FontFamilyNormalIcon,
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
icon: metadata.icon,
text: fonts[0].fontFace.family,
};
if (metadata.deprecated) {
@@ -92,7 +89,7 @@ export const FontPickerList = React.memo(
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFamilies()),
() => new Set(fonts.getSceneFontFamilies()),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],

View File

@@ -222,16 +222,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]}
isOr={false}
/>
<Shortcut
label={t("helpDialog.cropStart")}
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.cropFinish")}
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut
label={t("helpDialog.preventBinding")}

View File

@@ -100,14 +100,6 @@ const getHints = ({
return t("hints.text_editing");
}
if (appState.croppingElementId) {
return t("hints.leaveCropEditor");
}
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor");
}
if (activeTool.type === "selection") {
if (
appState.selectionElement &&

View File

@@ -48,9 +48,6 @@ const ChartPreviewBtn = (props: {
viewBackgroundColor: oc.white,
},
null, // files
{
skipInliningFonts: true,
},
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();

View File

@@ -203,8 +203,6 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement,
isCropping: appState.isCropping,
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches,
});

View File

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

View File

@@ -2147,12 +2147,3 @@ export const upIcon = createIcon(
</g>,
tablerIconProps,
);
export const cropIcon = createIcon(
<g strokeWidth="1.25">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 5v10a1 1 0 0 0 1 1h10" />
<path d="M5 8h10a1 1 0 0 1 1 1v10" />
</g>,
tablerIconProps,
);

View File

@@ -116,9 +116,6 @@ export const CLASSES = {
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
/**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
*
@@ -139,22 +136,6 @@ export const FONT_FAMILY = {
"Liberation Sans": 9,
};
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
default:
return [WINDOWS_EMOJI_FALLBACK_FONT];
}
};
export const THEME = {
LIGHT: "light",
DARK: "dark",
@@ -176,6 +157,8 @@ export const FRAME_STYLE = {
nameLineHeight: 1.25,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;

View File

@@ -2171,7 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#1098ad",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ANOTHER STYLED
"text": "ANOTHER STYLED
LABELLED ARROW",
"textAlign": "center",
"type": "text",
@@ -2179,8 +2179,8 @@ LABELLED ARROW",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 140,
"x": 80,
"width": 150,
"x": 75,
"y": 275,
}
`;
@@ -2213,7 +2213,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#099268",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ANOTHER STYLED
"text": "ANOTHER STYLED
LABELLED ARROW",
"textAlign": "center",
"type": "text",
@@ -2221,8 +2221,8 @@ LABELLED ARROW",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 140,
"x": 80,
"width": 150,
"x": 75,
"y": 375,
}
`;
@@ -2518,7 +2518,7 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ELLIPSE TEXT
"text": "ELLIPSE TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2526,8 +2526,8 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 120,
"x": 539.7893218813452,
"width": 130,
"x": 534.7893218813452,
"y": 117.44796179957173,
}
`;
@@ -2562,7 +2562,7 @@ TEXT CONTAINER",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "DIAMOND
TEXT
TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2646,8 +2646,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "TOP LEFT ALIGNED
RECTANGLE TEXT
"text": "TOP LEFT ALIGNED
RECTANGLE TEXT
CONTAINER",
"textAlign": "left",
"type": "text",
@@ -2655,7 +2655,7 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 160,
"width": 170,
"x": 505,
"y": 305,
}
@@ -2689,8 +2689,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "STYLED
ELLIPSE TEXT
"text": "STYLED
ELLIPSE TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2698,8 +2698,8 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 120,
"x": 539.7893218813452,
"width": 130,
"x": 534.7893218813452,
"y": 522.5735931288071,
}
`;

View File

@@ -190,10 +190,6 @@ const restoreElementWithProperties = <
}
return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element,
// normalized properties
...base,
...getNormalizedDimensions(base),
...extra,
@@ -262,7 +258,6 @@ const restoreElement = (
status: element.status || "pending",
fileId: element.fileId,
scale: element.scale || [1, 1],
crop: element.crop ?? null,
});
case "line":
// @ts-ignore LEGACY type

View File

@@ -1,587 +0,0 @@
import { type Point } from "points-on-curve";
import {
type Radians,
pointFrom,
pointCenter,
pointRotateRads,
vectorFromPoint,
vectorNormalize,
vectorSubtract,
vectorAdd,
vectorScale,
pointFromVector,
clamp,
isCloseTo,
} from "../../math";
import type { TransformHandleType } from "./transformHandles";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawImageElement,
ImageCrop,
NonDeleted,
} from "./types";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
transformHandle: TransformHandleType,
naturalWidth: number,
naturalHeight: number,
pointerX: number,
pointerY: number,
widthAspectRatio?: number,
) => {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
/**
* uncropped width
* **
* | (x,y) (natural) |
* | ** |
* | |///////| height | uncropped height
* | ** |
* | width (natural) |
* **
*/
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
-element.angle as Radians,
);
pointerX = rotatedPointer[0];
pointerY = rotatedPointer[1];
let nextWidth = element.width;
let nextHeight = element.height;
let crop: ImageCrop | null = element.crop ?? {
x: 0,
y: 0,
width: naturalWidth,
height: naturalHeight,
naturalWidth,
naturalHeight,
};
const previousCropHeight = crop.height;
const previousCropWidth = crop.width;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let changeInHeight = pointerY - element.y;
let changeInWidth = pointerX - element.x;
if (transformHandle.includes("n")) {
nextHeight = clamp(
element.height - changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
);
}
if (transformHandle.includes("s")) {
changeInHeight = pointerY - element.y - element.height;
nextHeight = clamp(
element.height + changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
);
}
if (transformHandle.includes("e")) {
changeInWidth = pointerX - element.x - element.width;
nextWidth = clamp(
element.width + changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
);
}
if (transformHandle.includes("w")) {
nextWidth = clamp(
element.width - changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
);
}
const updateCropWidthAndHeight = (crop: ImageCrop) => {
crop.height = nextHeight * naturalHeightToUncropped;
crop.width = nextWidth * naturalWidthToUncropped;
};
updateCropWidthAndHeight(crop);
const adjustFlipForHandle = (
handle: TransformHandleType,
crop: ImageCrop,
) => {
updateCropWidthAndHeight(crop);
if (handle.includes("n")) {
if (!isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("s")) {
if (isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("e")) {
if (isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
if (handle.includes("w")) {
if (!isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
};
switch (transformHandle) {
case "n": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "s": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "w": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "e": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "ne": {
if (widthAspectRatio) {
if (changeInWidth > -changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "nw": {
if (widthAspectRatio) {
if (changeInWidth < changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "se": {
if (widthAspectRatio) {
if (changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "sw": {
if (widthAspectRatio) {
if (-changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
default:
break;
}
const newOrigin = recomputeOrigin(
element,
transformHandle,
nextWidth,
nextHeight,
!!widthAspectRatio,
);
// reset crop to null if we're back to orig size
if (
isCloseTo(crop.width, crop.naturalWidth) &&
isCloseTo(crop.height, crop.naturalHeight)
) {
crop = null;
}
return {
x: newOrigin[0],
y: newOrigin[1],
width: nextWidth,
height: nextHeight,
crop,
};
};
const recomputeOrigin = (
stateAtCropStart: NonDeleted<ExcalidrawElement>,
transformHandle: TransformHandleType,
width: number,
height: number,
shouldMaintainAspectRatio?: boolean,
) => {
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtCropStart,
stateAtCropStart.width,
stateAtCropStart.height,
true,
);
const startTopLeft = pointFrom(x1, y1);
const startBottomRight = pointFrom(x2, y2);
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
// Calculate new topLeft based on fixed corner during resize
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandle)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startBottomRight[1] - Math.abs(newBoundsHeight),
];
}
if (transformHandle === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
}
if (transformHandle === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
}
if (shouldMaintainAspectRatio) {
if (["s", "n"].includes(transformHandle)) {
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
if (["e", "w"].includes(transformHandle)) {
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
}
}
// adjust topLeft to new rotation point
const angle = stateAtCropStart.angle;
const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtCropStart.x - newBoundsX1;
newOrigin[1] += stateAtCropStart.y - newBoundsY1;
return newOrigin;
};
// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
export const getUncroppedImageElement = (
element: ExcalidrawImageElement,
elementsMap: ElementsMap,
) => {
if (element.crop) {
const { width, height } = getUncroppedWidthAndHeight(element);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const topLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
);
const topRightVector = vectorFromPoint(
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
);
const topEdgeNormalized = vectorNormalize(
vectorSubtract(topRightVector, topLeftVector),
);
const bottomLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
);
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
const rotatedTopLeft = vectorAdd(
vectorAdd(
topLeftVector,
vectorScale(
topEdgeNormalized,
(-cropX * width) / element.crop.naturalWidth,
),
),
vectorScale(
leftEdgeNormalized,
(-cropY * height) / element.crop.naturalHeight,
),
);
const center = pointFromVector(
vectorAdd(
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
vectorScale(leftEdgeNormalized, height / 2),
),
);
const unrotatedTopLeft = pointRotateRads(
pointFromVector(rotatedTopLeft),
center,
-element.angle as Radians,
);
const uncroppedElement: ExcalidrawImageElement = {
...element,
x: unrotatedTopLeft[0],
y: unrotatedTopLeft[1],
width,
height,
crop: null,
};
return uncroppedElement;
}
return element;
};
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
if (element.crop) {
const width =
element.width / (element.crop.width / element.crop.naturalWidth);
const height =
element.height / (element.crop.height / element.crop.naturalHeight);
return {
width,
height,
};
}
return {
width: element.width,
height: element.height,
};
};
const adjustCropPosition = (
crop: ImageCrop,
scale: ExcalidrawImageElement["scale"],
) => {
let cropX = crop.x;
let cropY = crop.y;
const flipX = scale[0] === -1;
const flipY = scale[1] === -1;
if (flipX) {
cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
}
if (flipY) {
cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
}
return {
cropX,
cropY,
};
};

View File

@@ -16,7 +16,6 @@ import {
isArrowElement,
isElbowArrow,
isFrameLikeElement,
isImageElement,
isTextElement,
} from "./typeChecks";
import { getFontString } from "../utils";
@@ -252,14 +251,6 @@ export const dragNewElement = ({
}
if (width !== 0 && height !== 0) {
let imageInitialDimension = null;
if (isImageElement(newElement)) {
imageInitialDimension = {
initialWidth: width,
initialHeight: height,
};
}
mutateElement(
newElement,
{
@@ -268,7 +259,6 @@ export const dragNewElement = ({
width,
height,
...textAutoResize,
...imageInitialDimension,
},
informMutation,
);

View File

@@ -20,7 +20,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(?:embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:.*?[&?](?:t=|start=)([a-zA-Z0-9_-]+))?.*$/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@@ -105,22 +105,23 @@ export const getEmbedLink = (
let type: "video" | "generic" = "generic";
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
const id = ytLink?.[1];
if (id) {
const time = ytLink[2] ? `&start=${ytLink[2]}` : ``;
const isPortrait = link.includes("shorts");
type = "video";
switch (ytLink[1]) {
case "embed/":
case "watch?v=":
case "shorts/":
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
link = `https://www.youtube.com/embed/${id}?enablejsapi=1${time}`;
break;
case "playlist?list=":
case "embed/videoseries?list=":
link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
link = `https://www.youtube.com/embed/videoseries?list=${id}&enablejsapi=1${time}`;
break;
default:
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
link = `https://www.youtube.com/embed/${id}?enablejsapi=1${time}`;
break;
}
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };

View File

@@ -477,7 +477,6 @@ export const newImageElement = (
status?: ExcalidrawImageElement["status"];
fileId?: ExcalidrawImageElement["fileId"];
scale?: ExcalidrawImageElement["scale"];
crop?: ExcalidrawImageElement["crop"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => {
return {
@@ -488,7 +487,6 @@ export const newImageElement = (
status: opts.status ?? "pending",
fileId: opts.fileId ?? null,
scale: opts.scale ?? [1, 1],
crop: opts.crop ?? null,
};
};

View File

@@ -20,7 +20,7 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
import { isImageElement, isLinearElement } from "./typeChecks";
import { isLinearElement } from "./typeChecks";
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
import {
pointFrom,
@@ -90,11 +90,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
// do not resize from the sides for linear elements with only two points
if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = isImageElement(element)
? 0
: SIDE_RESIZING_THRESHOLD / zoom.value;
const ZOOMED_SIDE_RESIZING_THRESHOLD =
SIDE_RESIZING_THRESHOLD / zoom.value;
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
pointFrom(x1 - SPACING, y1 - SPACING),
pointFrom(x2 + SPACING, y2 + SPACING),
@@ -108,7 +104,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
pointOnLineSegment(
pointFrom(x, y),
side as LineSegment<Point>,
ZOOMED_SIDE_RESIZING_THRESHOLD,
SPACING,
)
) {
return dir as TransformHandleType;

View File

@@ -14,23 +14,20 @@ import {
import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
describe("Test wrapText", () => {
// font is irrelevant as jsdom does not support FontFace API
// `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
// https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
const font = "10px Cascadia, Segoe UI Emoji" as FontString;
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
it("should wrap the text correctly when word length is exactly equal to max width", () => {
const text = "Hello Excalidraw";
// Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello\nExcalidraw`);
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(text);
});
it("should return the text as is if max width is invalid", () => {
const text = "Hello Excalidraw";
expect(wrapText(text, font, NaN)).toEqual(text);
expect(wrapText(text, font, -1)).toEqual(text);
expect(wrapText(text, font, Infinity)).toEqual(text);
it("should work with emojis", () => {
const text = "😀";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀");
});
it("should show the text correctly when max width reached", () => {
@@ -40,237 +37,6 @@ describe("Test wrapText", () => {
expect(res).toBe("H\ne\nl\nl\no\n😀");
});
it("should not wrap number when wrapping line", () => {
const text = "don't wrap this number 99,100.99";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("don't wrap this number\n99,100.99");
});
it("should support multiple (multi-codepoint) emojis", () => {
const text = "😀🗺🔥👩🏽‍🦰👨‍👩‍👧‍👦🇨🇿";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀\n🗺\n🔥\n👩🏽🦰\n👨👩👧👦\n🇨🇿");
});
it("should wrap the text correctly when text contains hyphen", () => {
let text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
const res = wrapText(text, font, 110);
expect(res).toBe(
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
);
text = "Hello thereusing-now";
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
});
it("should support wrapping nested lists", () => {
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
const maxWidth = 100;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
});
describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
const text = "안녕하세요こんにちは世界コンニチハ你好";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
);
});
it("should break CJK text into longer segments when width is larger", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
const text = "안녕하세요こんにちは世界コンニチハ你好";
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
// measureText is mocked, so it's not precisely what would happen in prod
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
});
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
const maxWidth = 150;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
const maxWidth3 = 30;
const res3 = wrapText(text, font, maxWidth3);
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
});
it("should break before and after a regular CJK character", () => {
const text = "HelloたWorld";
const maxWidth1 = 50;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe("Hello\nた\nWorld");
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe("Helloた\nWorld");
});
it("should break before and after certain CJK symbols", () => {
const text = "こんにちは〃世界";
const maxWidth1 = 50;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe("こんにちは\n〃世界");
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe("こんにちは〃\n世界");
});
it("should break after, not before for certain CJK pairs", () => {
const text = "Hello た。";
const maxWidth = 70;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello\nた。");
});
it("should break before, not after for certain CJK pairs", () => {
const text = "Hello「たWorld」";
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello\n「た\nWorld」");
});
it("should break after, not before for certain CJK character pairs", () => {
const text = "「Helloた」World";
const maxWidth = 70;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("「Hello\nた」World");
});
it("should break Chinese sentences", () => {
const text = `中国你好!这是一个测试。
我们来看看人民币¥1234「很贵」
(括号)、逗号,句号。空格 换行 全角符号…—`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`中国你好!这是一\n个测试。
我们来看看:人民\n币¥1234「很\n贵」
(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`中国你好!\n这是一个测\n试。
我们来看\n看人民币\n¥1234\n「很贵」
(括号)、\n逗号\n号。空格\n换行 全角\n符号…—`);
});
});
it("should break Japanese sentences", () => {
const text = `日本こんにちは!これはテストです。
見てみましょう円¥1234「高い」
(括弧)、読点、句点。
空白 改行 全角記号…ー`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
見てみましょ\nう円¥1234\n「高い」
(括弧)、読\n点、句点。
空白 改行\n全角記号…ー`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`日本こんに\nちはこれ\nはテストで\nす。
見てみ\nましょう\n円\n¥1234\n「高い」
(括\n弧、読\n点、句点。
空白\n改行 全角\n記号…ー`);
});
it("should break Korean sentences", () => {
const text = `한국 안녕하세요! 이것은 테스트입니다.
우리 보자: 원화₩1234「비싸다」
(괄호), 쉼표, 마침표.
공백 줄바꿈 전각기호…—`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
우리 보자: 원\n화₩1234「비\n싸다」
(괄호), 쉼\n표, 마침표.
공백 줄바꿈 전\n각기호…—`);
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
우리 보자:\n원화\n₩1234\n「비싸다」
(괄호),\n쉼표, 마침\n표.
공백 줄바꿈\n전각기호…—`);
});
describe("When text contains leading whitespaces", () => {
const text = " \t Hello world";
it("should preserve leading whitespaces", () => {
const maxWidth = 120;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" \t Hello\nworld");
});
it("should break and collapse leading whitespaces when line breaks", () => {
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHello\nworld");
});
it("should break and collapse leading whitespaces whe words break", () => {
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHel\nlo\nwor\nld");
});
});
describe("When text contains trailing whitespaces", () => {
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(text);
});
it("should ignore trailing whitespaces when line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 400;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
});
it("should not ignore trailing whitespaces when word breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
});
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 180;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
});
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
@@ -278,7 +44,7 @@ describe("Test wrapText", () => {
{
desc: "break all words when width of each word is less than container width",
width: 80,
res: `Hello\nwhats\nup`,
res: `Hello \nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
@@ -300,7 +66,7 @@ p`,
desc: "break words as per the width",
width: 140,
res: `Hello whats\nup`,
res: `Hello whats \nup`,
},
{
desc: "fit the container",
@@ -330,7 +96,7 @@ whats up`;
{
desc: "break all words when width of each word is less than container width",
width: 80,
res: `Hello\nwhats\nup`,
res: `Hello\nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
@@ -376,24 +142,26 @@ whats up`,
{
desc: "fit characters of long string as per container width",
width: 170,
res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
},
{
desc: "fit characters of long string as per container width and break words as per the width",
width: 130,
res: `hellolongtex
tthisiswhats
upwithyouIam
typingggggan
dtypinggg
break it now`,
res: `hellolongte
xtthisiswha
tsupwithyou
Iamtypinggg
ggandtyping
gg break it
now`,
},
{
desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
@@ -403,243 +171,68 @@ break it now`,
});
});
describe("Test parseTokens", () => {
it("should tokenize latin", () => {
let text = "Excalidraw is a virtual collaborative whiteboard";
it("should wrap the text correctly when word length is exactly equal to max width", () => {
const text = "Hello Excalidraw";
// Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello \nExcalidraw`);
});
expect(parseTokens(text)).toEqual([
"Excalidraw",
" ",
"is",
" ",
"a",
" ",
"virtual",
" ",
"collaborative",
" ",
"whiteboard",
]);
it("should return the text as is if max width is invalid", () => {
const text = "Hello Excalidraw";
expect(wrapText(text, font, NaN)).toEqual(text);
expect(wrapText(text, font, -1)).toEqual(text);
expect(wrapText(text, font, Infinity)).toEqual(text);
});
text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
expect(parseTokens(text)).toEqual([
"Wikipedia",
" ",
"is",
" ",
"hosted",
" ",
"by",
" ",
"Wikimedia-",
" ",
"Foundation,",
" ",
"a",
" ",
"non-",
"profit",
" ",
"organization",
" ",
"that",
" ",
"also",
" ",
"hosts",
" ",
"a",
" ",
"range-",
"of",
" ",
"other",
" ",
"projects",
]);
});
it("should wrap the text correctly when text contains hyphen", () => {
let text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
const res = wrapText(text, font, 110);
expect(res).toBe(
`Wikipedia \nis hosted \nby \nWikimedia-\nFoundation,\na non-\nprofit \norganizati\non that \nalso hosts\na range-of\nother \nprojects`,
);
it("should not tokenize number", () => {
const text = "99,100.99";
const tokens = parseTokens(text);
expect(tokens).toEqual(["99,100.99"]);
});
text = "Hello thereusing-now";
expect(wrapText(text, font, 100)).toEqual("Hello \nthereusin\ng-now");
});
});
it("should tokenize joined emojis", () => {
const text = `😬🌍🗺🔥☂👩🏽🦰👨👩👧👦👩🏾🔬🏳🌈🧔🧑🤝🧑🙅🏽✅0⃣🇨🇿🦅`;
const tokens = parseTokens(text);
describe("Test parseTokens", () => {
it("should split into tokens correctly", () => {
let text = "Excalidraw is a virtual collaborative whiteboard";
expect(parseTokens(text)).toEqual([
"Excalidraw",
"is",
"a",
"virtual",
"collaborative",
"whiteboard",
]);
expect(tokens).toEqual([
"😬",
"🌍",
"🗺",
"🔥",
"☂️",
"👩🏽‍🦰",
"👨‍👩‍👧‍👦",
"👩🏾‍🔬",
"🏳️‍🌈",
"🧔‍♀️",
"🧑‍🤝‍🧑",
"🙅🏽‍♂️",
"✅",
"0",
"🇨🇿",
"🦅",
]);
});
it("should tokenize emojis mixed with mixed text", () => {
const text = `😬a🌍b🗺c🔥d☂《👩🏽🦰》👨👩👧👦德👩🏾🔬こ🏳🌈안🧔g🧑🤝🧑h🙅🏽e✅f0⃣g🇨🇿10🦅#hash`;
const tokens = parseTokens(text);
expect(tokens).toEqual([
"😬",
"a",
"🌍",
"b",
"🗺",
"c",
"🔥",
"d",
"☂️",
"《",
"👩🏽‍🦰",
"》",
"👨‍👩‍👧‍👦",
"德",
"👩🏾‍🔬",
"こ",
"🏳️‍🌈",
"안",
"🧔‍♀️",
"g",
"🧑‍🤝‍🧑",
"h",
"🙅🏽‍♂️",
"e",
"✅",
"f0⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
"🇨🇿",
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
"🦅",
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
]);
});
it("should tokenize decomposed chars into their composed variants", () => {
// each input character is in a decomposed form
const text = "čでäぴέ다й한";
expect(text.normalize("NFC").length).toEqual(8);
expect(text).toEqual(text.normalize("NFD"));
const tokens = parseTokens(text);
expect(tokens.length).toEqual(8);
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
});
it("should tokenize artificial CJK", () => {
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;다.다...원/달(((다)))[[1]]〚({((한))>)〛た…[Hello] Worldニューヨーク・¥3700.55す。090-1234-5678¥1,000〜5,000「素晴らしい重要Taro君30は、たなばた〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
// [
// '《道', '德', '經》', '醫-',
// '醫', 'こ', 'ん', 'に',
// 'ち', 'は', '世', '界!',
// '안', '녕', '하', '세',
// '요', '세', '계;', '다.',
// '다...', '원/', '달', '(((다)))',
// '[[1]]', '〚({((한))>)〛', 'た…', '[Hello]',
// ' ', 'World', 'ニ', 'ュ',
// 'ー', 'ヨ', 'ー', 'ク・',
// '¥3700.55', 'す。', '090-', '1234-',
// '5678¥1,000', '〜', '5,000', '「素',
// '晴', 'ら', 'し', 'い!」',
// '〔重', '要〕', '', '',
// 'Taro', '君', '30', 'は、',
// '(た', 'な', 'ば', 'た)',
// '〰', '¥110±', '¥570', 'で',
// '20℃', '〜', '9:30', '〜',
// '10:00', '【一', '番】'
// ]
const tokens = parseTokens(text);
// Latin
expect(tokens).toContain("[[1]]");
expect(tokens).toContain("[Hello]");
expect(tokens).toContain("World");
expect(tokens).toContain("Taro");
// Chinese
expect(tokens).toContain("《道");
expect(tokens).toContain("德");
expect(tokens).toContain("經》");
expect(tokens).toContain("醫-");
expect(tokens).toContain("醫");
// Japanese
expect(tokens).toContain("こ");
expect(tokens).toContain("ん");
expect(tokens).toContain("に");
expect(tokens).toContain("ち");
expect(tokens).toContain("は");
expect(tokens).toContain("世");
expect(tokens).toContain("ニ");
expect(tokens).toContain("ク・");
expect(tokens).toContain("界!");
expect(tokens).toContain("た…");
expect(tokens).toContain("す。");
expect(tokens).toContain("ュ");
expect(tokens).toContain("ー");
expect(tokens).toContain("「素");
expect(tokens).toContain("晴");
expect(tokens).toContain("ら");
expect(tokens).toContain("し");
expect(tokens).toContain("い!」");
expect(tokens).toContain("君");
expect(tokens).toContain("は、");
expect(tokens).toContain("(た");
expect(tokens).toContain("な");
expect(tokens).toContain("ば");
expect(tokens).toContain("た)");
expect(tokens).toContain("で");
expect(tokens).toContain("【一");
expect(tokens).toContain("番】");
// Check for Korean
expect(tokens).toContain("안");
expect(tokens).toContain("녕");
expect(tokens).toContain("하");
expect(tokens).toContain("세");
expect(tokens).toContain("요");
expect(tokens).toContain("세");
expect(tokens).toContain("계;");
expect(tokens).toContain("다.");
expect(tokens).toContain("다...");
expect(tokens).toContain("원/");
expect(tokens).toContain("달");
expect(tokens).toContain("(((다)))");
expect(tokens).toContain("〚({((한))>)〛");
// Numbers and units
expect(tokens).toContain("¥3700.55");
expect(tokens).toContain("090-");
expect(tokens).toContain("1234-");
expect(tokens).toContain("5678¥1,000");
expect(tokens).toContain("5,000");
expect(tokens).toContain("");
expect(tokens).toContain("30");
expect(tokens).toContain("¥110±");
expect(tokens).toContain("¥570");
expect(tokens).toContain("20℃");
expect(tokens).toContain("9:30");
expect(tokens).toContain("10:00");
// Punctuation and symbols
expect(tokens).toContain("〜");
expect(tokens).toContain("〰");
expect(tokens).toContain("");
});
text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
expect(parseTokens(text)).toEqual([
"Wikipedia",
"is",
"hosted",
"by",
"Wikimedia-",
"",
"Foundation,",
"a",
"non-",
"profit",
"organization",
"that",
"also",
"hosts",
"a",
"range-",
"of",
"other",
"projects",
]);
});
});

View File

@@ -16,7 +16,6 @@ import {
BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
@@ -31,172 +30,6 @@ import {
} from "./containerCache";
import type { ExtractSetType } from "../utility-types";
/**
* Matches various emoji types.
*
* 1. basic emojis (😀, 🌍)
* 2. flags (🇨🇿)
* 3. multi-codepoint emojis:
* - skin tones (👍🏽)
* - variation selectors (☂️)
* - keycaps (1⃣)
* - tag sequences (🏴󠁧󠁢󠁥󠁮󠁧󠁿)
* - emoji sequences (👨‍👩‍👧‍👦, 👩‍🚀, 🏳️‍🌈)
*
* Unicode points:
* - \uFE0F: presentation selector
* - \u20E3: enclosing keycap
* - \u200D: ZWJ (zero width joiner)
* - \u{E0020}-\u{E007E}: tags
* - \u{E007F}: cancel tag
*
* @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
* - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
* - replaced \p{Emod} with \p{Emoji_Modifier} as some do not understand the abbreviation (i.e. https://devina.io/redos-checker)
*/
const _EMOJI_CHAR =
/(\p{RI}\p{RI}|[\p{Extended_Pictographic}\p{Emoji_Presentation}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(?:\u200D(?:\p{RI}\p{RI}|[\p{Emoji}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*)/u;
/**
* Detect a CJK char, though does not include every possible char used in CJK texts,
* such as symbols and punctuations.
*
* By default every CJK is a breaking point, though CJK has additional breaking points,
* including full width punctuations or symbols (Chinese and Japanese) and western punctuations (Korean).
*
* Additional CJK breaking point rules:
* - expect a break before (lookahead), but not after (negative lookbehind), i.e. "(" or "("
* - expect a break after (lookbehind), but not before (negative lookahead), i.e. "" or ")"
* - expect a break always (lookahead and lookbehind), i.e. "〃"
*/
const _CJK_CHAR =
/\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}/u;
/**
* Following characters break only with CJK, not with alphabetic characters.
* This is essential for Korean, as it uses alphabetic punctuation, but expects CJK-like breaking points.
*
* Hello((た)) → ["Hello", "((た))"]
* Hello((World)) → ["Hello((World))"]
*/
const _CJK_BREAK_NOT_AFTER_BUT_BEFORE = /<\(\[\{/u;
const _CJK_BREAK_NOT_BEFORE_BUT_AFTER = />\)\]\}.,:;\?!/u;
const _CJK_BREAK_ALWAYS = / /u;
const _CJK_SYMBOLS_AND_PUNCTUATION =
//u;
/**
* Following characters break with any character, even though are mostly used with CJK.
*
* Hello た。→ ["Hello", "た。"]
* ↑ DON'T BREAK "た。" (negative lookahead)
* Hello「た」 World → ["Hello", "「た」", "World"]
* ↑ DON'T BREAK "「た" (negative lookbehind)
* ↑ DON'T BREAK "た」"(negative lookahead)
* ↑ BREAK BEFORE "「" (lookahead)
* ↑ BREAK AFTER "」" (lookbehind)
*/
const _ANY_BREAK_NOT_AFTER_BUT_BEFORE = //u;
const _ANY_BREAK_NOT_BEFORE_BUT_AFTER =
/±\//u;
/**
* Natural breaking points for any grammars.
*
* Hello-world
* ↑ BREAK AFTER "-" → ["Hello-", "world"]
* Hello world
* ↑ BREAK ALWAYS " " → ["Hello", " ", "world"]
*/
const _ANY_BREAK_AFTER = /-/u;
const _ANY_BREAK_ALWAYS = /\s/u;
/**
* Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
*
* Browser support as of 10/2024:
* - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
* - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
*
* Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
*/
const BREAK_LINE_REGEX_SIMPLE = new RegExp(
`${_EMOJI_CHAR.source}|([${_ANY_BREAK_ALWAYS.source}${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`,
"u",
);
// Hello World → ["Hello", " World"]
// ↑ BREAK BEFORE " "
// HelloたWorld → ["Hello", "たWorld"]
// ↑ BREAK BEFORE "た"
// Hello「World」→ ["Hello", "「World」"]
// ↑ BREAK BEFORE "「"
const getLookaheadBreakingPoints = () => {
const ANY_BREAKING_POINT = `(?<![${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}])(?=[${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}${_ANY_BREAK_ALWAYS.source}])`;
const CJK_BREAKING_POINT = `(?<![${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}${_CJK_BREAK_NOT_AFTER_BUT_BEFORE.source}])(?=[${_CJK_BREAK_NOT_AFTER_BUT_BEFORE.source}]*[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}])`;
return new RegExp(`(?:${ANY_BREAKING_POINT}|${CJK_BREAKING_POINT})`, "u");
};
// Hello World → ["Hello ", "World"]
// ↑ BREAK AFTER " "
// Hello-World → ["Hello-", "World"]
// ↑ BREAK AFTER "-"
// HelloたWorld → ["Helloた", "World"]
// ↑ BREAK AFTER "た"
//「Hello」World → ["「Hello」", "World"]
// ↑ BREAK AFTER "」"
const getLookbehindBreakingPoints = () => {
const ANY_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}])(?<=[${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`;
const CJK_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_AFTER.source}])(?<=[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}][${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}]*)`;
return new RegExp(`(?:${ANY_BREAKING_POINT}|${CJK_BREAKING_POINT})`, "u");
};
/**
* Break a line based on the whitespaces, CJK / emoji chars and language specific breaking points,
* like hyphen for alphabetic and various full-width codepoints for CJK - especially Japanese, e.g.:
*
* "Hello 世界。🌎🗺" → ["Hello", " ", "世", "界。", "🌎", "🗺"]
* "Hello-world" → ["Hello-", "world"]
* "「Hello World」" → ["「Hello", " ", "World」"]
*/
const getBreakLineRegexAdvanced = () =>
new RegExp(
`${_EMOJI_CHAR.source}|${getLookaheadBreakingPoints().source}|${
getLookbehindBreakingPoints().source
}`,
"u",
);
let cachedBreakLineRegex: RegExp | undefined;
// Lazy-load for browsers that don't support "Lookbehind assertion"
const getBreakLineRegex = () => {
if (!cachedBreakLineRegex) {
try {
cachedBreakLineRegex = getBreakLineRegexAdvanced();
} catch {
cachedBreakLineRegex = BREAK_LINE_REGEX_SIMPLE;
}
}
return cachedBreakLineRegex;
};
const CJK_REGEX = new RegExp(
`[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_CJK_SYMBOLS_AND_PUNCTUATION.source}]`,
"u",
);
const EMOJI_REGEX = new RegExp(`${_EMOJI_CHAR.source}`, "u");
export const containsCJK = (text: string) => {
return CJK_REGEX.test(text);
};
export const containsEmoji = (text: string) => {
return EMOJI_REGEX.test(text);
};
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
@@ -575,132 +408,22 @@ export const getTextHeight = (
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const parseTokens = (line: string) => {
const breakLineRegex = getBreakLineRegex();
// normalizing to single-codepoint composed chars due to canonical equivalence of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
// filtering due to multi-codepoint chars like 👨‍👩‍👧‍👦, 👩🏽‍🦰
return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
};
// handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨‍👩‍👧‍👦, 👩🏽‍🦰)
const isSingleCharacter = (maybeSingleCharacter: string) => {
return (
maybeSingleCharacter.codePointAt(0) !== undefined &&
maybeSingleCharacter.codePointAt(1) === undefined
);
};
const satisfiesWordInvariant = (word: string) => {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
if (/\s/.test(word)) {
throw new Error("Word should not contain any whitespaces!");
}
export const parseTokens = (text: string) => {
// Splitting words containing "-" as those are treated as separate words
// by css wrapping algorithm eg non-profit => non-, profit
const words = text.split("-");
if (words.length > 1) {
// non-proft org => ['non-', 'profit org']
words.forEach((word, index) => {
if (index !== words.length - 1) {
words[index] = word += "-";
}
});
}
};
const wrapWord = (
word: string,
font: FontString,
maxWidth: number,
): Array<string> => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (EMOJI_REGEX.test(word)) {
return [word];
}
satisfiesWordInvariant(word);
const lines: Array<string> = [];
const chars = Array.from(word);
let currentLine = "";
let currentLineWidth = 0;
for (const char of chars) {
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
currentLine = currentLine + char;
currentLineWidth = testLineWidth;
continue;
}
if (currentLine) {
lines.push(currentLine);
}
currentLine = char;
currentLineWidth = _charWidth;
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
};
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
): string[] => {
const lines: Array<string> = [];
const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = "";
let currentLineWidth = 0;
let iterator = tokenIterator.next();
while (!iterator.done) {
const token = iterator.value;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
const testLineWidth = isSingleCharacter(token)
? currentLineWidth + charWidth.calculate(token, font)
: getLineWidth(testLine, font, true);
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
currentLine = testLine;
currentLineWidth = testLineWidth;
iterator = tokenIterator.next();
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font, true);
iterator = tokenIterator.next();
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd());
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
lines.push(currentLine.trimEnd());
}
return lines;
// Joining the words with space and splitting them again with space to get the
// final list of tokens
// ['non-', 'profit org'] =>,'non- proft org' => ['non-','profit','org']
return words.join(" ").split(" ");
};
export const wrapText = (
@@ -717,17 +440,134 @@ export const wrapText = (
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceAdvanceWidth = getLineWidth(" ", font, true);
let currentLine = "";
let currentLineWidthTillNow = 0;
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
const resetParams = () => {
currentLine = "";
currentLineWidthTillNow = 0;
};
for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true);
// Push the line if its <= maxWidth
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
continue;
}
const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
const words = parseTokens(originalLine);
resetParams();
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font, true);
// This will only happen when single word takes entire width
if (currentWordWidth === maxWidth) {
push(words[index]);
index++;
}
// Start breaking longer words exceeding max width
else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
resetParams();
while (words[index].length > 0) {
const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!,
);
const line = currentLine + currentChar;
// use advance width instead of the actual width as it's closest to the browser wapping algo
// use width of the whole line instead of calculating individual chars to accomodate for kerning
const lineAdvanceWidth = getLineWidth(line, font, true);
const charAdvanceWidth = charWidth.calculate(currentChar, font);
currentLineWidthTillNow = lineAdvanceWidth;
words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = charAdvanceWidth;
} else {
currentLine = line;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
push(currentLine);
resetParams();
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line unless the line ends with hyphen to sync
// with css word-wrap
} else if (!currentLine.endsWith("-")) {
currentLine += " ";
currentLineWidthTillNow += spaceAdvanceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(
currentLine + word,
font,
true,
);
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
resetParams();
break;
}
index++;
// if word ends with "-" then we don't need to add space
// to sync with css word-wrap
const shouldAppendSpace = !word.endsWith("-");
currentLine += word;
if (shouldAppendSpace) {
currentLine += " ";
}
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
if (shouldAppendSpace) {
lines.push(currentLine.slice(0, -1));
} else {
lines.push(currentLine);
}
resetParams();
break;
}
}
}
}
if (currentLine.slice(-1) === " ") {
// only remove last trailing space which we have added when joining words
currentLine = currentLine.slice(0, -1);
push(currentLine);
}
}
return lines.join("\n");
@@ -737,30 +577,24 @@ export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font, true);
cachedCharWidth[font][unicode] = width;
cachedCharWidth[font][ascii] = width;
}
return cachedCharWidth[font][unicode];
return cachedCharWidth[font][ascii];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();

View File

@@ -917,7 +917,7 @@ describe("textWysiwyg", () => {
Keyboard.exitTextEditor(editor);
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.text).toBe("Hello\nWorld!");
expect(text.text).toBe("Hello \nWorld!");
expect(text.originalText).toBe("Hello World!");
expect(text.y).toBe(
rectangle.y + h.elements[0].height / 2 - text.height / 2,
@@ -1220,7 +1220,7 @@ describe("textWysiwyg", () => {
);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
"Online\nwhiteboa\nrd\ncollabor\nation\nmade\neasy",
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,

View File

@@ -11,7 +11,6 @@ import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import {
isElbowArrow,
isFrameLikeElement,
isImageElement,
isLinearElement,
} from "./typeChecks";
import {
@@ -130,7 +129,6 @@ export const getTransformHandlesFromCoords = (
pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {},
margin = 4,
spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
): TransformHandles => {
const size = transformHandleSizes[pointerType];
const handleWidth = size / zoom.value;
@@ -142,7 +140,8 @@ export const getTransformHandlesFromCoords = (
const width = x2 - x1;
const height = y2 - y1;
const dashedLineMargin = margin / zoom.value;
const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
const centeringOffset =
(size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value);
const transformHandles: TransformHandles = {
nw: omitSides.nw
@@ -302,10 +301,8 @@ export const getTransformHandles = (
rotation: true,
};
}
const margin = isLinearElement(element)
const dashedLineMargin = isLinearElement(element)
? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
: isImageElement(element)
? 0
: DEFAULT_TRANSFORM_HANDLE_SPACING;
return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element, elementsMap, true),
@@ -313,8 +310,7 @@ export const getTransformHandles = (
zoom,
pointerType,
omitSides,
margin,
isImageElement(element) ? 0 : undefined,
dashedLineMargin,
);
};

View File

@@ -132,15 +132,6 @@ export type IframeData =
| { type: "document"; srcdoc: (theme: Theme) => string }
);
export type ImageCrop = {
x: number;
y: number;
width: number;
height: number;
naturalWidth: number;
naturalHeight: number;
};
export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{
type: "image";
@@ -149,8 +140,6 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
status: "pending" | "saved" | "error";
/** X and Y scale factors <-1, 1>, used for image axis flipping */
scale: [number, number];
/** whether an element is cropped */
crop: ImageCrop | null;
}>;
export type InitializedExcalidrawImageElement = MarkNonNullable<

View File

@@ -36,28 +36,3 @@ export class ImageSceneDataError extends Error {
export class InvalidFractionalIndexError extends Error {
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
}
type WorkerErrorCodes = "WORKER_URL_NOT_DEFINED" | "WORKER_IN_THE_MAIN_CHUNK";
export class WorkerUrlNotDefinedError extends Error {
public code;
constructor(
message = "Worker URL is not defined!",
code: WorkerErrorCodes = "WORKER_URL_NOT_DEFINED",
) {
super(message);
this.name = "WorkerUrlNotDefinedError";
this.code = code;
}
}
export class WorkerInTheMainChunkError extends Error {
public code;
constructor(
message = "Worker has to be in a separate chunk!",
code: WorkerErrorCodes = "WORKER_IN_THE_MAIN_CHUNK",
) {
super(message);
this.name = "WorkerInTheMainChunkError";
this.code = code;
}
}

View File

@@ -1,9 +0,0 @@
import CascadiaCodeRegular from "./CascadiaCode-Regular.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const CascadiaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: CascadiaCodeRegular,
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,96 +0,0 @@
// The following file content was generated with https://chinese-font.netlify.app/online-split,
// but has been manully rewritten from `@font-face` rules into TS while leveraging FontFace API.
import _0 from "./ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2";
import _1 from "./ComicShanns-Regular-fcb0fc02dcbee4c9846b3e2508668039.woff2";
import _2 from "./ComicShanns-Regular-dc6a8806fa96795d7b3be5026f989a17.woff2";
import _3 from "./ComicShanns-Regular-6e066e8de2ac57ea9283adb9c24d7f0c.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
/* Generated By cn-font-split@5.2.2 https://www.npmjs.com/package/cn-font-split
CreateTime: Thu, 17 Oct 2024 09:57:51 GMT;
Origin File Name Table:
copyright: MIT License
Copyright (c) 2018 Shannon Miwa
Copyright (c) 2023 Jesus Gonzalez
Copyright (c) 2023 Rodrigo Batista de Moraes
Copyright (c) 2024 Fini Jastrow
Copyright (c) 2024 Kyle Beechly
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
fontFamily: Comic Shanns Mono-Regular
fontSubfamily: Regular
uniqueID: FontForge 2.0 : Comic Shanns Mono Regular : 17-10-2024
fullName: Comic Shanns Mono Regular
version: 1.3.0
postScriptName: ComicShannsMono-Regular
license: MIT License
Copyright (c) 2018 Shannon Miwa
Copyright (c) 2023 Jesus Gonzalez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
preferredFamily: Comic Shanns Mono
*/
export const ComicShannsFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: _0,
descriptors: {
unicodeRange:
"U+20-7e,U+a1-a6,U+a8,U+ab-ac,U+af-b1,U+b4,U+b8,U+bb-bc,U+bf-cf,U+d1-d7,U+d9-de,U+e0-ef,U+f1-f7,U+f9-ff,U+131,U+152-153,U+2c6,U+2da,U+2dc,U+2013-2014,U+2018-201a,U+201c-201d,U+2020-2022,U+2026,U+2039-203a,U+2044,U+20ac,U+2191,U+2193,U+2212",
},
},
{
uri: _1,
descriptors: {
unicodeRange:
"U+100-10f,U+112-125,U+128-130,U+134-137,U+139-13c,U+141-148,U+14c-151,U+154-161,U+164-165,U+168-17f,U+1bf,U+1f7,U+218-21b,U+237,U+1e80-1e85,U+1ef2-1ef3,U+a75b",
},
},
{
uri: _2,
descriptors: {
unicodeRange:
"U+2c7,U+2d8-2d9,U+2db,U+2dd,U+315,U+2190,U+2192,U+2200,U+2203-2204,U+2264-2265,U+f6c3",
},
},
{
uri: _3,
descriptors: { unicodeRange: "U+3bb" },
},
];

View File

@@ -1,9 +0,0 @@
import { LOCAL_FONT_PROTOCOL } from "../FontMetadata";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const EmojiFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LOCAL_FONT_PROTOCOL,
},
];

View File

@@ -0,0 +1,214 @@
import {
base64ToArrayBuffer,
stringToBase64,
toByteString,
} from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata";
import loadWoff2 from "./wasm/woff2.loader";
import loadHbSubset from "./wasm/hb-subset.loader";
export interface Font {
urls: URL[];
fontFace: FontFace;
getContent(codePoints: ReadonlySet<number>): Promise<string>;
}
export const UNPKG_FALLBACK_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
export class ExcalidrawFont implements Font {
public readonly urls: URL[];
public readonly fontFace: FontFace;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
this.urls = ExcalidrawFont.createUrls(uri);
const sources = this.urls
.map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
.join(", ");
this.fontFace = new FontFace(family, sources, {
display: "swap",
style: "normal",
weight: "400",
...descriptors,
});
}
/**
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
*
* NOTE: assumes usage of `dataurl` outside the browser environment
*
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
*/
public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
let i = 0;
const errorMessages = [];
while (i < this.urls.length) {
const url = this.urls[i];
// it's dataurl (server), the font is inlined as base64, no need to fetch
if (url.protocol === "data:") {
const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
try {
const response = await fetch(url, {
headers: {
Accept: "font/woff2",
},
});
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
// response not ok, try to continue
errorMessages.push(
`"${url.toString()}" returned status "${response.status}"`,
);
} catch (e) {
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
}
i++;
}
console.error(
`Failed to fetch font "${
this.fontFace.family
}" from urls "${this.urls.toString()}`,
JSON.stringify(errorMessages, undefined, 2),
);
// in case of issues, at least return the last url as a content
// defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}
/**
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl.
*
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
* @param codePoints codepoints used to subset the glyphs
*
* @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl
*/
private static async subsetGlyphsByCodePoints(
arrayBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
): Promise<string> {
try {
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers
const { compress, decompress } = await loadWoff2();
const { subset } = await loadHbSubset();
const decompressedBinary = decompress(arrayBuffer).buffer;
const subsetSnft = subset(decompressedBinary, codePoints);
const compressedBinary = compress(subsetSnft.buffer);
return ExcalidrawFont.toBase64(compressedBinary.buffer);
} catch (e) {
console.error("Skipped glyph subsetting", e);
// Fallback to encoding whole font in case of errors
return ExcalidrawFont.toBase64(arrayBuffer);
}
}
private static async toBase64(arrayBuffer: ArrayBuffer) {
let base64: string;
if (typeof Buffer !== "undefined") {
// node + server-side
base64 = Buffer.from(arrayBuffer).toString("base64");
} else {
base64 = await stringToBase64(await toByteString(arrayBuffer), true);
}
return `data:font/woff2;base64,${base64}`;
}
private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts
return [];
}
if (uri.startsWith("http") || uri.startsWith("data")) {
// one url for http imports or data url
return [new URL(uri)];
}
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
const assetUrl: string = uri.replace(/^\/+/, "");
const urls: URL[] = [];
if (typeof window.EXCALIDRAW_ASSET_PATH === "string") {
const normalizedBaseUrl = this.normalizeBaseUrl(
window.EXCALIDRAW_ASSET_PATH,
);
urls.push(new URL(assetUrl, normalizedBaseUrl));
} else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) {
window.EXCALIDRAW_ASSET_PATH.forEach((path) => {
const normalizedBaseUrl = this.normalizeBaseUrl(path);
urls.push(new URL(assetUrl, normalizedBaseUrl));
});
}
// fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));
return urls;
}
private static getFormat(url: URL) {
try {
const parts = new URL(url).pathname.split(".");
if (parts.length === 1) {
return "";
}
return `format('${parts.pop()}')`;
} catch (error) {
return "";
}
}
private static normalizeBaseUrl(baseUrl: string) {
let result = baseUrl;
// in case user passed a root-relative url (~absolute path),
// like "/" or "/some/path", or relative (starts with "./"),
// prepend it with `location.origin`
if (/^\.?\//.test(result)) {
result = new URL(
result.replace(/^\.?\/+/, ""),
window?.location?.origin,
).toString();
}
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
result = `${result.replace(/\/+$/, "")}/`;
return result;
}
}

View File

@@ -1,209 +0,0 @@
import { promiseTry } from "../utils";
import { LOCAL_FONT_PROTOCOL } from "./FontMetadata";
import { subsetWoff2GlyphsByCodepoints } from "../subset/subset-main";
type DataURL = string;
export class ExcalidrawFontFace {
public readonly urls: URL[] | DataURL[];
public readonly fontFace: FontFace;
private static readonly UNPKG_FALLBACK_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
this.urls = ExcalidrawFontFace.createUrls(uri);
const sources = this.urls
.map((url) => `url(${url}) ${ExcalidrawFontFace.getFormat(url)}`)
.join(", ");
this.fontFace = new FontFace(family, sources, {
display: "swap",
style: "normal",
weight: "400",
...descriptors,
});
}
/**
* Generates CSS `@font-face` definition with the (subsetted) font source as a data url for the characters within the unicode range.
*
* Retrieves `undefined` otherwise.
*/
public toCSS(characters: string): Promise<string> | undefined {
// quick exit in case the characters are not within this font face's unicode range
if (!this.getUnicodeRangeRegex().test(characters)) {
return;
}
const codepoints = Array.from(characters).map(
(char) => char.codePointAt(0)!,
);
return this.getContent(codepoints).then(
(content) =>
`@font-face { font-family: ${this.fontFace.family}; src: url(${content}); }`,
);
}
/**
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
*
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
*/
public async getContent(codePoints: Array<number>): Promise<string> {
let i = 0;
const errorMessages = [];
while (i < this.urls.length) {
const url = this.urls[i];
try {
const arrayBuffer = await this.fetchFont(url);
const base64 = await subsetWoff2GlyphsByCodepoints(
arrayBuffer,
codePoints,
);
return base64;
} catch (e) {
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
}
i++;
}
console.error(
`Failed to fetch font family "${this.fontFace.family}"`,
JSON.stringify(errorMessages, undefined, 2),
);
// in case of issues, at least return the last url as a content
// defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}
public fetchFont(url: URL | DataURL): Promise<ArrayBuffer> {
return promiseTry(async () => {
const response = await fetch(url, {
// always prefer cache (even stale), otherwise it always triggers an unnecessary validation request
// which we don't need as we are controlling freshness of the fonts with the stable hash suffix in the url
// https://developer.mozilla.org/en-US/docs/Web/API/Request/cache
cache: "force-cache",
headers: {
Accept: "font/woff2",
},
});
if (!response.ok) {
const urlString = url instanceof URL ? url.toString() : "dataurl";
throw new Error(
`Failed to fetch "${urlString}": ${response.statusText}`,
);
}
const arrayBuffer = await response.arrayBuffer();
return arrayBuffer;
});
}
private getUnicodeRangeRegex() {
// using \u{h} or \u{hhhhh} to match any number of hex digits,
// otherwise we would get an "Invalid Unicode escape" error
// e.g. U+0-1007F -> \u{0}-\u{1007F}
const unicodeRangeRegex = this.fontFace.unicodeRange
.split(/,\s*/)
.map((range) => {
const [start, end] = range.replace("U+", "").split("-");
if (end) {
return `\\u{${start}}-\\u{${end}}`;
}
return `\\u{${start}}`;
})
.join("");
return new RegExp(`[${unicodeRangeRegex}]`, "u");
}
private static createUrls(uri: string): URL[] | DataURL[] {
if (uri.startsWith("data")) {
// don't create the URL instance, as parsing the huge dataurl string is expensive
return [uri];
}
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts
return [];
}
if (uri.startsWith("http")) {
// one url for http imports or data url
return [new URL(uri)];
}
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
const assetUrl: string = uri.replace(/^\/+/, "");
const urls: URL[] = [];
if (typeof window.EXCALIDRAW_ASSET_PATH === "string") {
const normalizedBaseUrl = this.normalizeBaseUrl(
window.EXCALIDRAW_ASSET_PATH,
);
urls.push(new URL(assetUrl, normalizedBaseUrl));
} else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) {
window.EXCALIDRAW_ASSET_PATH.forEach((path) => {
const normalizedBaseUrl = this.normalizeBaseUrl(path);
urls.push(new URL(assetUrl, normalizedBaseUrl));
});
}
// fallback url for bundled fonts
urls.push(new URL(assetUrl, ExcalidrawFontFace.UNPKG_FALLBACK_URL));
return urls;
}
private static getFormat(url: URL | DataURL) {
if (!(url instanceof URL)) {
// format is irrelevant for data url
return "";
}
try {
const parts = new URL(url).pathname.split(".");
if (parts.length === 1) {
return "";
}
return `format('${parts.pop()}')`;
} catch (error) {
return "";
}
}
private static normalizeBaseUrl(baseUrl: string) {
let result = baseUrl;
// in case user passed a root-relative url (~absolute path),
// like "/" or "/some/path", or relative (starts with "./"),
// prepend it with `location.origin`
if (/^\.?\//.test(result)) {
result = new URL(
result.replace(/^\.?\/+/, ""),
window?.location?.origin,
).toString();
}
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
result = `${result.replace(/\/+$/, "")}/`;
return result;
}
}

View File

@@ -1,160 +0,0 @@
import _0 from "./Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2";
import _1 from "./Excalifont-Regular-be310b9bcd4f1a43f571c46df7809174.woff2";
import _2 from "./Excalifont-Regular-b9dcf9d2e50a1eaf42fc664b50a3fd0d.woff2";
import _3 from "./Excalifont-Regular-41b173a47b57366892116a575a43e2b6.woff2";
import _4 from "./Excalifont-Regular-3f2c5db56cc93c5a6873b1361d730c16.woff2";
import _5 from "./Excalifont-Regular-349fac6ca4700ffec595a7150a0d1e1d.woff2";
import _6 from "./Excalifont-Regular-623ccf21b21ef6b3a0d87738f77eb071.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
/* Generated By cn-font-split@5.2.2 https://www.npmjs.com/package/cn-font-split
CreateTime: Mon, 14 Oct 2024 18:59:19 GMT;
Origin File Name Table:
copyright: Copyright (c) 2024 by Excalidraw. All rights reserved.
fontFamily: Excalifont
fontSubfamily: Regular
uniqueID: 1.000;DSGN;Excalifont
fullName: Excalifont Regular
version: Version 1.000;Glyphs 3.2 (3227)
postScriptName: Excalifont-Regular
trademark: Excalifont is a trademark of Excalidraw.
manufacturer: Your Own Font Foundry (Virgil); Ján Filípek / DizajnDesign (Excalifont, modifications)
designer: Your Own Font Foundry (Virgil); Ján Filípek / DizajnDesign (Excalifont, modifications)
manufacturerURL: https://dizajndesign.sk
designerURL: https://dizajndesign.sk
license: This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
licenseURL: http://scripts.sil.org/OFL
preferredFamily: Excalifont
preferredSubfamily: Regular
*/
export const ExcalifontFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: _0,
descriptors: {
unicodeRange:
"U+20-7e,U+a0-a3,U+a5-a6,U+a8-ab,U+ad-b1,U+b4,U+b6-b8,U+ba-ff,U+131,U+152-153,U+2bc,U+2c6,U+2da,U+2dc,U+304,U+308,U+2013-2014,U+2018-201a,U+201c-201e,U+2020,U+2022,U+2024-2026,U+2030,U+2039-203a,U+20ac,U+2122,U+2212",
},
},
{
uri: _1,
descriptors: {
unicodeRange:
"U+100-130,U+132-137,U+139-149,U+14c-151,U+154-17e,U+192,U+1fc-1ff,U+218-21b,U+237,U+1e80-1e85,U+1ef2-1ef3,U+2113",
},
},
{ uri: _2, descriptors: { unicodeRange: "U+400-45f,U+490-491,U+2116" } },
{
uri: _3,
descriptors: {
unicodeRange:
"U+37e,U+384-38a,U+38c,U+38e-393,U+395-3a1,U+3a3-3a8,U+3aa-3cf,U+3d7",
},
},
{
uri: _4,
descriptors: {
unicodeRange:
"U+2c7,U+2d8-2d9,U+2db,U+2dd,U+302,U+306-307,U+30a-30c,U+326-328,U+212e,U+2211,U+fb01-fb02",
},
},
{
uri: _5,
descriptors: {
unicodeRange:
"U+462-463,U+472-475,U+4d8-4d9,U+4e2-4e3,U+4e6-4e9,U+4ee-4ef",
},
},
{ uri: _6, descriptors: { unicodeRange: "U+300-301,U+303" } },
];

View File

@@ -1,349 +0,0 @@
import {
FONT_FAMILY,
FONT_FAMILY_FALLBACKS,
CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "../constants";
import { isTextElement } from "../element";
import { charWidth, getContainerElement } from "../element/textElement";
import { ShapeCache } from "../scene/ShapeCache";
import { getFontString } from "../utils";
import { ExcalidrawFontFace } from "./ExcalidrawFontFace";
import { CascadiaFontFaces } from "./Cascadia";
import { ComicShannsFontFaces } from "./ComicShanns";
import { EmojiFontFaces } from "./Emoji";
import { ExcalifontFontFaces } from "./Excalifont";
import { HelveticaFontFaces } from "./Helvetica";
import { LiberationFontFaces } from "./Liberation";
import { LilitaFontFaces } from "./Lilita";
import { NunitoFontFaces } from "./Nunito";
import { VirgilFontFaces } from "./Virgil";
import { XiaolaiFontFaces } from "./Xiaolai";
import { FONT_METADATA, type FontMetadata } from "./FontMetadata";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
FontFamilyValues,
} from "../element/types";
import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types";
export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use
// a static member to reduce memory footprint
public static readonly loadedFontsCache = new Set<string>();
private static _registered:
| Map<
number,
{
metadata: FontMetadata;
fontFaces: ExcalidrawFontFace[];
}
>
| undefined;
private static _initialized: boolean = false;
public static get registered() {
// lazy load the font registration
if (!Fonts._registered) {
Fonts._registered = Fonts.init();
} else if (!Fonts._initialized) {
// case when host app register fonts before they are lazy loaded
// don't override whatever has been previously registered
Fonts._registered = new Map([
...Fonts.init().entries(),
...Fonts._registered.entries(),
]);
}
return Fonts._registered;
}
public get registered() {
return Fonts.registered;
}
private readonly scene: Scene;
constructor(scene: Scene) {
this.scene = scene;
}
/**
* if we load a (new) font, it's likely that text elements using it have
* already been rendered using a fallback font. Thus, we want invalidate
* their shapes and rerender. See #637.
*
* Invalidates text elements and rerenders scene, provided that at least one
* of the supplied fontFaces has not already been processed.
*/
public onLoaded = (fontFaces: readonly FontFace[]) => {
// bail if all fonts with have been processed. We're checking just a
// subset of the font properties (though it should be enough), so it
// can technically bail on a false positive.
let shouldBail = true;
for (const fontFace of fontFaces) {
const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}-${fontFace.unicodeRange}`;
// make sure to update our cache with all the loaded font faces
if (!Fonts.loadedFontsCache.has(sig)) {
Fonts.loadedFontsCache.add(sig);
shouldBail = false;
}
}
if (shouldBail) {
return;
}
let didUpdate = false;
const elementsMap = this.scene.getNonDeletedElementsMap();
for (const element of this.scene.getNonDeletedElements()) {
if (isTextElement(element)) {
didUpdate = true;
ShapeCache.delete(element);
// clear the width cache, so that we don't perform subsequent wrapping based on the stale fallback font metrics
charWidth.clearCache(getFontString(element));
const container = getContainerElement(element, elementsMap);
if (container) {
ShapeCache.delete(container);
}
}
}
if (didUpdate) {
this.scene.triggerUpdate();
}
};
/**
* Load font faces for a given scene and trigger scene update.
*/
public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFamilies();
const loaded = await Fonts.loadFontFaces(sceneFamilies);
this.onLoaded(loaded);
return loaded;
};
/**
* Load all registered font faces.
*/
public static loadAllFonts = async (): Promise<FontFace[]> => {
const allFamilies = Fonts.getAllFamilies();
return Fonts.loadFontFaces(allFamilies);
};
/**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*/
public static loadElementsFonts = async (
elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => {
const fontFamilies = Fonts.getElementsFamilies(elements);
return await Fonts.loadFontFaces(fontFamilies);
};
private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
) {
// add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces, metadata } of Fonts.registered.values()) {
// skip registering font faces for local fonts (i.e. Helvetica)
if (metadata.local) {
continue;
}
for (const { fontFace } of fontFaces) {
if (!window.document.fonts.has(fontFace)) {
window.document.fonts.add(fontFace);
}
}
}
const loadedFontFaces = await Promise.all(
fontFamilies.map(async (fontFamily) => {
const fontString = getFontString({
fontFamily,
fontSize: 16,
});
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
if (!window.document.fonts.check(fontString)) {
try {
// WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
// we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
return await window.document.fonts.load(fontString);
} catch (e) {
// don't let it all fail if just one font fails to load
console.error(
`Failed to load font "${fontString}" from urls "${Fonts.registered
.get(fontFamily)
?.fontFaces.map((x) => x.urls)}"`,
e,
);
}
}
return Promise.resolve();
}),
);
return loadedFontFaces.flat().filter(Boolean) as FontFace[];
}
/**
* WARN: should be called just once on init, even across multiple instances.
*/
private static init() {
const fonts = {
registered: new Map<
ValueOf<typeof FONT_FAMILY | typeof FONT_FAMILY_FALLBACKS>,
{ metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
>(),
};
const init = (
family: keyof typeof FONT_FAMILY | keyof typeof FONT_FAMILY_FALLBACKS,
...fontFacesDescriptors: ExcalidrawFontFaceDescriptor[]
) => {
const fontFamily =
FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
// default to Excalifont metrics
const metadata =
FONT_METADATA[fontFamily] ?? FONT_METADATA[FONT_FAMILY.Excalifont];
Fonts.register.call(fonts, family, metadata, ...fontFacesDescriptors);
};
init("Cascadia", ...CascadiaFontFaces);
init("Comic Shanns", ...ComicShannsFontFaces);
init("Excalifont", ...ExcalifontFontFaces);
// keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
init("Helvetica", ...HelveticaFontFaces);
// used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
init("Liberation Sans", ...LiberationFontFaces);
init("Lilita One", ...LilitaFontFaces);
init("Nunito", ...NunitoFontFaces);
init("Virgil", ...VirgilFontFaces);
// fallback font faces
init(CJK_HAND_DRAWN_FALLBACK_FONT, ...XiaolaiFontFaces);
init(WINDOWS_EMOJI_FALLBACK_FONT, ...EmojiFontFaces);
Fonts._initialized = true;
return fonts.registered;
}
/**
* Register a new font.
*
* @param family font family
* @param metadata font metadata
* @param fontFacesDecriptors font faces descriptors
*/
private static register(
this:
| Fonts
| {
registered: Map<
number,
{ metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
>;
},
family: string,
metadata: FontMetadata,
...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[]
) {
// TODO: likely we will need to abandon number value in order to support custom fonts
const fontFamily =
FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
const registeredFamily = this.registered.get(fontFamily);
if (!registeredFamily) {
this.registered.set(fontFamily, {
metadata,
fontFaces: fontFacesDecriptors.map(
({ uri, descriptors }) =>
new ExcalidrawFontFace(family, uri, descriptors),
),
});
}
return this.registered;
}
/**
* Gets all the font families for the given scene.
*/
public getSceneFamilies = () => {
return Fonts.getElementsFamilies(this.scene.getNonDeletedElements());
};
private static getAllFamilies() {
return Array.from(Fonts.registered.keys());
}
private static getElementsFamilies(
elements: ReadonlyArray<ExcalidrawElement>,
): Array<ExcalidrawTextElement["fontFamily"]> {
return Array.from(
elements.reduce((families, element) => {
if (isTextElement(element)) {
families.add(element.fontFamily);
}
return families;
}, new Set<number>()),
);
}
}
/**
* Calculates vertical offset for a text with alphabetic baseline.
*/
export const getVerticalOffset = (
fontFamily: ExcalidrawTextElement["fontFamily"],
fontSize: ExcalidrawTextElement["fontSize"],
lineHeightPx: number,
) => {
const { unitsPerEm, ascender, descender } =
Fonts.registered.get(fontFamily)?.metadata.metrics ||
FONT_METADATA[FONT_FAMILY.Virgil].metrics;
const fontSizeEm = fontSize / unitsPerEm;
const lineGap =
(lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2;
const verticalOffset = fontSizeEm * ascender + lineGap;
return verticalOffset;
};
/**
* Gets line height forr a selected family.
*/
export const getLineHeight = (fontFamily: FontFamilyValues) => {
const { lineHeight } =
Fonts.registered.get(fontFamily)?.metadata.metrics ||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
return lineHeight as ExcalidrawTextElement["lineHeight"];
};
export interface ExcalidrawFontFaceDescriptor {
uri: string;
descriptors?: FontFaceDescriptors;
}

View File

@@ -1,9 +0,0 @@
import { LOCAL_FONT_PROTOCOL } from "../FontMetadata";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const HelveticaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LOCAL_FONT_PROTOCOL,
},
];

View File

@@ -1,9 +0,0 @@
import LiberationSansRegular from "./LiberationSans-Regular.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const LiberationFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LiberationSansRegular,
},
];

View File

@@ -1,17 +0,0 @@
import LilitaLatin from "./Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
import LilitaLatinExt from "./Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
import { GOOGLE_FONTS_RANGES } from "../FontMetadata";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const LilitaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LilitaLatinExt,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN_EXT },
},
{
uri: LilitaLatin,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN },
},
];

View File

@@ -1,38 +0,0 @@
import Latin from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
import LatinExt from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
import Cyrilic from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
import CyrilicExt from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
import Vietnamese from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
import { GOOGLE_FONTS_RANGES } from "../FontMetadata";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const NunitoFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: CyrilicExt,
descriptors: {
unicodeRange: GOOGLE_FONTS_RANGES.CYRILIC_EXT,
weight: "500",
},
},
{
uri: Cyrilic,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.CYRILIC, weight: "500" },
},
{
uri: Vietnamese,
descriptors: {
unicodeRange: GOOGLE_FONTS_RANGES.VIETNAMESE,
weight: "500",
},
},
{
uri: LatinExt,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN_EXT, weight: "500" },
},
{
uri: Latin,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN, weight: "500" },
},
];

View File

@@ -1,9 +0,0 @@
import Virgil from "./Virgil-Regular.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const VirgilFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: Virgil,
},
];

Some files were not shown because too many files have changed in this diff Show More