Compare commits

...

10 Commits

Author SHA1 Message Date
dependabot[bot] e3d1dee9d0 build(deps): bump micromatch from 4.0.7 to 4.0.8 (#8450)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-02 00:37:49 +02:00
hocino e3f31df747 docs : update dead link on main-menu page (#8454) 2024-09-01 22:31:13 +00:00
David Luzar 60e75406e0 refactor: remove unused env variable (#8457) 2024-09-02 00:25:12 +02:00
zsviczian b396e07b90 fix: PropertiesPopover maxWidth changing fixed units to relative units (#8456) 2024-09-01 18:18:37 +02:00
zsviczian 2d1d84a47b fix: View mode wheel zooming does not work (#8452)
* Update App.tsx

* fix: prevent zooming browser over DOM

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-31 12:06:38 +02:00
Marcel Mraz ee30225062 feat: subset font glyphs for SVG export (#8384)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-30 22:26:06 +02:00
David Luzar 16cae4fc07 feat: reset copyStatus on export dialog settings change (#8443) 2024-08-29 15:10:08 +02:00
David Luzar 576bc0dbe5 feat: tweak copy button success animation (#8441) 2024-08-29 00:43:01 +02:00
David Luzar 00af35c692 feat: enable panning/zoom while in wysiwyg (#8437) 2024-08-29 00:42:46 +02:00
Márk Tolmács ea7c702cfc feat: Visual debugger (#8344)
Add visual debugger to the Excalidraw app (only).
2024-08-27 19:46:00 +02:00
38 changed files with 5414 additions and 160 deletions
-2
View File
@@ -17,8 +17,6 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
#
# whether to enable Service Workers in development
VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
@@ -133,7 +133,7 @@ function App() {
}
```
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items.
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
### MainMenu.Group
+38 -1
View File
@@ -120,6 +120,11 @@ import {
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
debugRenderer,
isVisualDebuggerEnabled,
loadSavedDebugState,
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
polyfill();
@@ -337,6 +342,8 @@ const ExcalidrawWrapper = () => {
resolvablePromise<ExcalidrawInitialDataState | null>();
}
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
trackEvent("load", "frame", getFrame());
// Delayed so that the app has a time to load the latest SW
@@ -362,6 +369,23 @@ const ExcalidrawWrapper = () => {
migrationAdapter: LibraryLocalStorageMigrationAdapter,
});
const [, forceRefresh] = useState(false);
useEffect(() => {
if (import.meta.env.DEV) {
const debugState = loadSavedDebugState();
if (debugState.enabled && !window.visualDebug) {
window.visualDebug = {
data: [],
};
} else {
delete window.visualDebug;
}
forceRefresh((prev) => !prev);
}
}, [excalidrawAPI]);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
@@ -622,6 +646,11 @@ const ExcalidrawWrapper = () => {
}
});
}
// Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) {
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
@@ -820,6 +849,7 @@ const ExcalidrawWrapper = () => {
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@@ -845,7 +875,7 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter />
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<TTDDialogTrigger />
@@ -1077,6 +1107,13 @@ const ExcalidrawWrapper = () => {
},
]}
/>
{isVisualDebuggerEnabled() && excalidrawAPI && (
<DebugCanvas
appState={excalidrawAPI.getAppState()}
scale={window.devicePixelRatio}
ref={debugCanvasRef}
/>
)}
</Excalidraw>
</div>
);
+1
View File
@@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_THEME: "excalidraw-theme",
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
+23 -19
View File
@@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
export const AppFooter = React.memo(() => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
});
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
},
);
+20
View File
@@ -2,11 +2,13 @@ import React from "react";
import {
loginIcon,
ExcalLogo,
eyeIcon,
} from "../../packages/excalidraw/components/icons";
import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList";
import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
@@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => {
return (
<MainMenu>
@@ -50,6 +53,23 @@ export const AppMainMenu: React.FC<{
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
{import.meta.env.DEV && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
} else {
window.visualDebug = { data: [] };
saveDebugState({ enabled: true });
}
props?.refresh();
}}
>
Visual Debug
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
+293
View File
@@ -0,0 +1,293 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "../../packages/excalidraw/types";
import { throttleRAF } from "../../packages/excalidraw/utils";
import type { LineSegment } from "../../packages/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "../../packages/excalidraw/renderer/helpers";
import type { DebugElement } from "../../packages/excalidraw/visualdebug";
import {
ArrowheadArrowIcon,
CloseIcon,
TrashIcon,
} from "../../packages/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants";
import { isLineSegment } from "../../packages/excalidraw/element/typeChecks";
const renderLine = (
context: CanvasRenderingContext2D,
zoom: number,
segment: LineSegment,
color: string,
) => {
context.save();
context.strokeStyle = color;
context.beginPath();
context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
context.stroke();
context.restore();
};
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888";
context.save();
context.beginPath();
context.moveTo(-10 * zoom, -10 * zoom);
context.lineTo(10 * zoom, 10 * zoom);
context.moveTo(10 * zoom, -10 * zoom);
context.lineTo(-10 * zoom, 10 * zoom);
context.stroke();
context.save();
};
const render = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
appState: AppState,
) => {
frame.forEach((el) => {
switch (true) {
case isLineSegment(el.data):
renderLine(context, appState.zoom.value, el.data, el.color);
break;
}
});
};
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
scale: number,
) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
viewBackgroundColor: "transparent",
});
// Apply zoom
context.save();
context.translate(
appState.scrollX * appState.zoom.value,
appState.scrollY * appState.zoom.value,
);
renderOrigin(context, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
window.visualDebug?.data &&
window.visualDebug.data.length > 0
) {
// Render only one frame
const [idx] = debugFrameData();
render(window.visualDebug.data[idx], context, appState);
} else {
// Render all debug frames
window.visualDebug?.data.forEach((frame) => {
render(frame, context, appState);
});
}
if (window.visualDebug) {
window.visualDebug!.data =
window.visualDebug?.data.map((frame) =>
frame.filter((el) => el.permanent),
) ?? [];
}
};
const debugFrameData = (): [number, number] => {
const currentFrame = window.visualDebug?.currentFrame ?? 0;
const frameCount = window.visualDebug?.data.length ?? 0;
if (frameCount > 0) {
return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
}
return [0, 0];
};
export const saveDebugState = (debug: { enabled: boolean }) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
JSON.stringify(debug),
);
} catch (error: any) {
console.error(error);
}
};
export const debugRenderer = throttleRAF(
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => {
_debugRenderer(canvas, appState, scale);
},
{ trailing: true },
);
export const loadSavedDebugState = () => {
let debug;
try {
const savedDebugState = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
);
if (savedDebugState) {
debug = JSON.parse(savedDebugState) as { enabled: boolean };
}
} catch (error: any) {
console.error(error);
}
return debug ?? { enabled: false };
};
export const isVisualDebuggerEnabled = () =>
Array.isArray(window.visualDebug?.data);
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
const moveForward = useCallback(() => {
if (
!window.visualDebug?.currentFrame ||
isNaN(window.visualDebug?.currentFrame ?? -1)
) {
window.visualDebug!.currentFrame = 0;
}
window.visualDebug!.currentFrame += 1;
onChange();
}, [onChange]);
const moveBackward = useCallback(() => {
if (
!window.visualDebug?.currentFrame ||
isNaN(window.visualDebug?.currentFrame ?? -1) ||
window.visualDebug?.currentFrame < 1
) {
window.visualDebug!.currentFrame = 1;
}
window.visualDebug!.currentFrame -= 1;
onChange();
}, [onChange]);
const reset = useCallback(() => {
window.visualDebug!.currentFrame = undefined;
onChange();
}, [onChange]);
const trashFrames = useCallback(() => {
if (window.visualDebug) {
window.visualDebug.currentFrame = undefined;
window.visualDebug.data = [];
}
onChange();
}, [onChange]);
return (
<>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={trashFrames}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
{TrashIcon}
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={moveBackward}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
<ArrowheadArrowIcon flip />
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={reset}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
{CloseIcon}
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-backward"
aria-label="Move backward"
type="button"
onClick={moveForward}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
<ArrowheadArrowIcon />
</div>
</button>
</>
);
};
interface DebugCanvasProps {
appState: AppState;
scale: number;
}
const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
},
);
export default DebugCanvas;
-23
View File
@@ -128,29 +128,6 @@
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
+13 -2
View File
@@ -73,8 +73,8 @@ export default defineConfig({
},
workbox: {
// Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
// Don't push fonts, locales and wasm to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
@@ -108,6 +108,17 @@ export default defineConfig({
},
},
},
{
urlPattern: new RegExp(".wasm-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {
@@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueGlyphs = new Set<string>();
let uniqueChars = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
@@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({
}
if (!skipFontFaceCheck) {
uniqueGlyphs = new Set([
...uniqueGlyphs,
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
@@ -919,12 +919,9 @@ export const actionChangeFontFamily = register({
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const glyphs = Array.from(uniqueGlyphs.values()).join();
const chars = Array.from(uniqueChars.values()).join();
if (
skipFontFaceCheck ||
window.document.fonts.check(fontString, glyphs)
) {
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
@@ -936,8 +933,8 @@ export const actionChangeFontFamily = register({
);
}
} else {
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, chars).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id);
+1 -1
View File
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
export const trackEvent = (
category: string,
+2 -1
View File
@@ -49,6 +49,7 @@ import {
} from "./icons";
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants";
export const canChangeStrokeColor = (
appState: UIAppState,
@@ -426,7 +427,7 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"];
zoom: Zoom;
}) => (
<Stack.Col gap={1} className="zoom-actions">
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
<Stack.Row align="center">
{renderAction("zoomOut")}
{renderAction("resetZoom")}
+31 -18
View File
@@ -2515,7 +2515,7 @@ class App extends React.Component<AppProps, AppState> {
addEventListener(
this.excalidrawContainerRef.current,
EVENT.WHEEL,
this.onWheel,
this.handleWheel,
{ passive: false },
),
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
@@ -2578,6 +2578,11 @@ class App extends React.Component<AppProps, AppState> {
addEventListener(window, EVENT.RESIZE, this.onResize, false),
addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
addEventListener(window, EVENT.BLUR, this.onBlur, false),
addEventListener(
this.excalidrawContainerRef.current,
EVENT.WHEEL,
this.handleWheel,
),
addEventListener(
this.excalidrawContainerRef.current,
EVENT.DRAG_OVER,
@@ -4318,13 +4323,6 @@ class App extends React.Component<AppProps, AppState> {
},
);
private onWheel = withBatchedUpdates((event: WheelEvent) => {
// prevent browser pinch zoom on DOM elements
if (!(event.target instanceof HTMLCanvasElement) && event.ctrlKey) {
event.preventDefault();
}
});
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) {
if (this.state.viewModeEnabled) {
@@ -6384,8 +6382,8 @@ class App extends React.Component<AppProps, AppState> {
};
// Returns whether the event is a panning
private handleCanvasPanUsingWheelOrSpaceDrag = (
event: React.PointerEvent<HTMLElement>,
public handleCanvasPanUsingWheelOrSpaceDrag = (
event: React.PointerEvent<HTMLElement> | MouseEvent,
): boolean => {
if (
!(
@@ -6394,13 +6392,16 @@ class App extends React.Component<AppProps, AppState> {
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
isHandToolActive(this.state) ||
this.state.viewModeEnabled)
) ||
this.state.editingTextElement
)
) {
return false;
}
isPanning = true;
event.preventDefault();
if (!this.state.editingTextElement) {
// preventing defualt while text editing messes with cursor/focus
event.preventDefault();
}
let nextPastePrevented = false;
const isLinux =
@@ -9472,7 +9473,6 @@ class App extends React.Component<AppProps, AppState> {
// NOTE wheel, touchstart, touchend events must be registered outside
// of react because react binds them them passively (so we can't prevent
// default on them)
this.interactiveCanvas.addEventListener(EVENT.WHEEL, this.handleWheel);
this.interactiveCanvas.addEventListener(
EVENT.TOUCH_START,
this.onTouchStart,
@@ -9480,10 +9480,6 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
// -----------------------------------------------------------------------
} else {
this.interactiveCanvas?.removeEventListener(
EVENT.WHEEL,
this.handleWheel,
);
this.interactiveCanvas?.removeEventListener(
EVENT.TOUCH_START,
this.onTouchStart,
@@ -10078,7 +10074,24 @@ class App extends React.Component<AppProps, AppState> {
(
event: WheelEvent | React.WheelEvent<HTMLDivElement | HTMLCanvasElement>,
) => {
// if not scrolling on canvas/wysiwyg, ignore
if (
!(
event.target instanceof HTMLCanvasElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLIFrameElement
)
) {
// prevent zooming the browser (but allow scrolling DOM)
if (event[KEYS.CTRL_OR_CMD]) {
event.preventDefault();
}
return;
}
event.preventDefault();
if (isPanning) {
return;
}
@@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
return (
<PropertiesPopover
container={container}
style={{ maxWidth: "208px" }}
style={{ maxWidth: "13rem" }}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
@@ -1,5 +1,19 @@
@import "../css/variables.module.scss";
@keyframes successStatusAnimation {
0% {
transform: scale(0.35);
}
50% {
transform: scale(1.25);
}
100% {
transform: scale(1);
}
}
.excalidraw {
.ExcButton {
--text-color: transparent;
@@ -22,9 +36,10 @@
visibility: visible;
position: absolute;
width: 1rem;
height: 1rem;
font-size: 1rem;
width: 1.2rem;
height: 1.2rem;
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
}
&.ExcButton--status-loading,
@@ -90,7 +90,20 @@ const ImageExportModal = ({
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus } = useCopyStatus();
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
useEffect(() => {
// if user changes setting right after export to clipboard, reset the status
// so they don't have to wait for the timeout to click the button again
resetCopyStatus();
}, [
projectName,
exportWithBackground,
exportDarkMode,
exportScale,
embedScene,
resetCopyStatus,
]);
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
@@ -108,6 +121,7 @@ const ImageExportModal = ({
if (!maxWidth) {
return;
}
exportToCanvas({
elements: exportedElements,
appState: {
@@ -133,6 +133,7 @@ const SingleLibraryItem = ({
exportBackground: true,
},
files: null,
skipInliningFonts: true,
});
node.innerHTML = svg.outerHTML;
})();
+1
View File
@@ -112,6 +112,7 @@ export const ENV = {
export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
};
/**
+1 -1
View File
@@ -387,7 +387,7 @@ body.excalidraw-cursor-resize * {
.App-menu__left {
overflow-y: auto;
padding: 0.75rem;
width: 200px;
width: 12.5rem;
box-sizing: border-box;
position: absolute;
}
+39 -14
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, isSafari } from "../constants";
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -38,7 +38,11 @@ import {
actionDecreaseFontSize,
actionIncreaseFontSize,
} from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import {
actionResetZoom,
actionZoomIn,
actionZoomOut,
} from "../actions/actionCanvas";
import type App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
@@ -379,6 +383,10 @@ export const textWysiwyg = ({
event.preventDefault();
app.actionManager.executeAction(actionZoomOut);
updateWysiwygStyle();
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
event.preventDefault();
app.actionManager.executeAction(actionResetZoom);
updateWysiwygStyle();
} else if (actionDecreaseFontSize.keyTest(event)) {
app.actionManager.executeAction(actionDecreaseFontSize);
} else if (actionIncreaseFontSize.keyTest(event)) {
@@ -593,6 +601,7 @@ export const textWysiwyg = ({
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
unbindUpdate();
unbindOnScroll();
editable.remove();
};
@@ -619,10 +628,29 @@ export const textWysiwyg = ({
});
};
const temporarilyDisableSubmit = () => {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
};
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const target = event?.target;
// panning canvas
if (event.button === POINTER_BUTTON.WHEEL) {
// trying to pan by clicking inside text area itself -> handle here
if (target instanceof HTMLTextAreaElement) {
event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
}
temporarilyDisableSubmit();
return;
}
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
@@ -630,17 +658,14 @@ export const textWysiwyg = ({
if (
((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) &&
!isWritableElement(event.target)) ||
isPropertiesTrigger
) {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
temporarilyDisableSubmit();
} else if (
event.target instanceof HTMLElement &&
event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
// so without introducing crazier hacks, nothing we can do
@@ -659,7 +684,7 @@ export const textWysiwyg = ({
};
// handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
const unbindUpdate = app.scene.onUpdate(() => {
updateWysiwygStyle();
const isPopupOpened = !!document.activeElement?.closest(
".properties-content",
@@ -669,6 +694,10 @@ export const textWysiwyg = ({
}
});
const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
updateWysiwygStyle();
});
// ---------------------------------------------------------------------------
let isDestroyed = false;
@@ -699,10 +728,6 @@ export const textWysiwyg = ({
requestAnimationFrame(() => {
window.addEventListener("pointerdown", onPointerDown, { capture: true });
});
window.addEventListener("wheel", stopEvent, {
passive: false,
capture: true,
});
window.addEventListener("beforeunload", handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
+23 -1
View File
@@ -1,7 +1,9 @@
import type { LineSegment } from "../../utils";
import { ROUNDNESS } from "../constants";
import type { ElementOrToolType } from "../types";
import type { ElementOrToolType, Point } from "../types";
import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import type { Bounds } from "./bounds";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -322,3 +324,23 @@ export const isFixedPointBinding = (
): binding is FixedPointBinding => {
return binding.fixedPoint != null;
};
// TODO: Move this to @excalidraw/math
export const isPoint = (point: unknown): point is Point =>
Array.isArray(point) && point.length === 2;
// TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
box.length === 4 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
// TODO: Move this to @excalidraw/math
export const isLineSegment = (segment: unknown): segment is LineSegment =>
Array.isArray(segment) &&
segment.length === 2 &&
isPoint(segment[0]) &&
isPoint(segment[0]);
+71 -17
View File
@@ -1,12 +1,14 @@
import { 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(): Promise<string>;
getContent(codePoints: ReadonlySet<number>): Promise<string>;
}
export const UNPKG_PROD_URL = `https://unpkg.com/${
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)
@@ -32,21 +34,32 @@ export class ExcalidrawFont implements Font {
}
/**
* Tries to fetch woff2 content, based on the registered urls.
* Returns last defined url in case of errors.
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
*
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
* 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(): Promise<string> {
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:") {
// it's dataurl, the font is inlined as base64, no need to fetch
return url.toString();
const arrayBuffer = Buffer.from(
url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
try {
@@ -57,13 +70,13 @@ export class ExcalidrawFont implements Font {
});
if (response.ok) {
const mimeType = await response.headers.get("Content-Type");
const buffer = await response.arrayBuffer();
const arrayBuffer = await response.arrayBuffer();
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return `data:${mimeType};base64,${await stringToBase64(
await toByteString(buffer),
true,
)}`;
return base64;
}
// response not ok, try to continue
@@ -89,6 +102,48 @@ export class ExcalidrawFont implements Font {
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
@@ -118,15 +173,14 @@ export class ExcalidrawFont implements Font {
}
// fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));
return urls;
}
private static getFormat(url: URL) {
try {
const pathname = new URL(url).pathname;
const parts = pathname.split(".");
const parts = new URL(url).pathname.split(".");
if (parts.length === 1) {
return "";
@@ -0,0 +1,202 @@
/**
* Modified version of hb-subset bindings from "subset-font" package https://github.com/papandreou/subset-font/blob/3f711c8aa29a426c7f22655861abfb976950f527/index.js
*
* CHANGELOG:
* - removed dependency on node APIs to work inside the browser
* - removed dependency on font fontverter for brotli compression
* - removed dependencies on lodash and p-limit
* - removed options for preserveNameIds, variationAxes, noLayoutClosure (not needed for now)
* - replaced text input with codepoints
* - rewritten in typescript and with esm modules
Copyright (c) 2012, Andreas Lind Petersen
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the author nor the names of contributors may
be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// function HB_TAG(str) {
// return str.split("").reduce((a, ch) => {
// return (a << 8) + ch.charCodeAt(0);
// }, 0);
// }
function subset(
hbSubsetWasm: any,
heapu8: Uint8Array,
font: ArrayBuffer,
codePoints: ReadonlySet<number>,
) {
const input = hbSubsetWasm.hb_subset_input_create_or_fail();
if (input === 0) {
throw new Error(
"hb_subset_input_create_or_fail (harfbuzz) returned zero, indicating failure",
);
}
const fontBuffer = hbSubsetWasm.malloc(font.byteLength);
heapu8.set(new Uint8Array(font), fontBuffer);
// Create the face
const blob = hbSubsetWasm.hb_blob_create(
fontBuffer,
font.byteLength,
2, // HB_MEMORY_MODE_WRITABLE
0,
0,
);
const face = hbSubsetWasm.hb_face_create(blob, 0);
hbSubsetWasm.hb_blob_destroy(blob);
// Do the equivalent of --font-features=*
const layoutFeatures = hbSubsetWasm.hb_subset_input_set(
input,
6, // HB_SUBSET_SETS_LAYOUT_FEATURE_TAG
);
hbSubsetWasm.hb_set_clear(layoutFeatures);
hbSubsetWasm.hb_set_invert(layoutFeatures);
// if (preserveNameIds) {
// const inputNameIds = harfbuzzJsWasm.hb_subset_input_set(
// input,
// 4, // HB_SUBSET_SETS_NAME_ID
// );
// for (const nameId of preserveNameIds) {
// harfbuzzJsWasm.hb_set_add(inputNameIds, nameId);
// }
// }
// if (noLayoutClosure) {
// harfbuzzJsWasm.hb_subset_input_set_flags(
// input,
// harfbuzzJsWasm.hb_subset_input_get_flags(input) | 0x00000200, // HB_SUBSET_FLAGS_NO_LAYOUT_CLOSURE
// );
// }
// Add unicodes indices
const inputUnicodes = hbSubsetWasm.hb_subset_input_unicode_set(input);
for (const c of codePoints) {
hbSubsetWasm.hb_set_add(inputUnicodes, c);
}
// if (variationAxes) {
// for (const [axisName, value] of Object.entries(variationAxes)) {
// if (typeof value === "number") {
// // Simple case: Pin/instance the variation axis to a single value
// if (
// !harfbuzzJsWasm.hb_subset_input_pin_axis_location(
// input,
// face,
// HB_TAG(axisName),
// value,
// )
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `hb_subset_input_pin_axis_location (harfbuzz) returned zero when pinning ${axisName} to ${value}, indicating failure. Maybe the axis does not exist in the font?`,
// );
// }
// } else if (value && typeof value === "object") {
// // Complex case: Reduce the variation space of the axis
// if (
// typeof value.min === "undefined" ||
// typeof value.max === "undefined"
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `${axisName}: You must provide both a min and a max value when setting the axis range`,
// );
// }
// if (
// !harfbuzzJsWasm.hb_subset_input_set_axis_range(
// input,
// face,
// HB_TAG(axisName),
// value.min,
// value.max,
// // An explicit NaN makes harfbuzz use the existing default value, clamping to the new range if necessary
// value.default ?? NaN,
// )
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `hb_subset_input_set_axis_range (harfbuzz) returned zero when setting the range of ${axisName} to [${value.min}; ${value.max}] and a default value of ${value.default}, indicating failure. Maybe the axis does not exist in the font?`,
// );
// }
// }
// }
// }
let subset;
try {
subset = hbSubsetWasm.hb_subset_or_fail(face, input);
if (subset === 0) {
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
throw new Error(
"hb_subset_or_fail (harfbuzz) returned zero, indicating failure. Maybe the input file is corrupted?",
);
}
} finally {
// Clean up
hbSubsetWasm.hb_subset_input_destroy(input);
}
// Get result blob
const result = hbSubsetWasm.hb_face_reference_blob(subset);
const offset = hbSubsetWasm.hb_blob_get_data(result, 0);
const subsetByteLength = hbSubsetWasm.hb_blob_get_length(result);
if (subsetByteLength === 0) {
hbSubsetWasm.hb_blob_destroy(result);
hbSubsetWasm.hb_face_destroy(subset);
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
throw new Error(
"Failed to create subset font, maybe the input file is corrupted?",
);
}
const subsetFont = new Uint8Array(
heapu8.subarray(offset, offset + subsetByteLength),
);
// Clean up
hbSubsetWasm.hb_blob_destroy(result);
hbSubsetWasm.hb_face_destroy(subset);
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
return subsetFont;
}
export default {
subset,
};
@@ -0,0 +1,58 @@
/**
* Lazy loads wasm and respective bindings for font subsetting based on the harfbuzzjs.
*/
let loadedWasm: ReturnType<typeof load> | null = null;
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{
subset: (
fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
) => Uint8Array;
}> => {
return new Promise(async (resolve, reject) => {
try {
const [binary, bindings] = await Promise.all([
import("./hb-subset.wasm"),
import("./hb-subset.bindings"),
]);
WebAssembly.instantiate(binary.default).then((module) => {
try {
const harfbuzzJsWasm = module.instance.exports;
// @ts-expect-error since `.buffer` is custom prop
const heapu8 = new Uint8Array(harfbuzzJsWasm.memory.buffer);
const hbSubset = {
subset: (
fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
) => {
return bindings.default.subset(
harfbuzzJsWasm,
heapu8,
fontBuffer,
codePoints,
);
},
};
resolve(hbSubset);
} catch (e) {
reject(e);
}
});
} catch (error) {
reject(error);
}
});
};
// lazy load the default export
export default (): ReturnType<typeof load> => {
if (!loadedWasm) {
loadedWasm = load();
}
return loadedWasm;
};
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
/**
* Lazy loads wasm and respective bindings for woff2 compression and decompression.
*/
type Vector = any;
let loadedWasm: ReturnType<typeof load> | null = null;
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{
compress: (buffer: ArrayBuffer) => Uint8Array;
decompress: (buffer: ArrayBuffer) => Uint8Array;
}> => {
return new Promise(async (resolve, reject) => {
try {
const [binary, bindings] = await Promise.all([
import("./woff2.wasm"),
import("./woff2.bindings"),
]);
// initializing the module manually, so that we could pass in the wasm binary
bindings
.default({ wasmBinary: binary.default })
.then(
(module: {
woff2Enc: (buffer: ArrayBuffer, byteLength: number) => Vector;
woff2Dec: (buffer: ArrayBuffer, byteLength: number) => Vector;
}) => {
try {
// re-map from internal vector into byte array
function convertFromVecToUint8Array(vector: Vector): Uint8Array {
const arr = [];
for (let i = 0, l = vector.size(); i < l; i++) {
arr.push(vector.get(i));
}
return new Uint8Array(arr);
}
// re-exporting only compress and decompress functions (also avoids infinite loop inside emscripten bindings)
const woff2 = {
compress: (buffer: ArrayBuffer) =>
convertFromVecToUint8Array(
module.woff2Enc(buffer, buffer.byteLength),
),
decompress: (buffer: ArrayBuffer) =>
convertFromVecToUint8Array(
module.woff2Dec(buffer, buffer.byteLength),
),
};
resolve(woff2);
} catch (e) {
reject(e);
}
},
);
} catch (e) {
reject(e);
}
});
};
// lazy loaded default export
export default (): ReturnType<typeof load> => {
if (!loadedWasm) {
loadedWasm = load();
}
return loadedWasm;
};
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
const TIMEOUT = 2000;
@@ -15,8 +15,13 @@ export const useCopyStatus = () => {
}, TIMEOUT);
};
const resetCopyStatus = useCallback(() => {
setCopyStatus(null);
}, []);
return {
copyStatus,
resetCopyStatus,
onCopy,
};
};
@@ -18,6 +18,7 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
},
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
});
};
@@ -40,6 +41,7 @@ export const useLibraryItemSvg = (
// When there is no svg in cache export it and save to cache
(async () => {
const exportedSvg = await exportLibraryItemToSvg(elements);
// TODO: should likely be removed for custom fonts
exportedSvg.querySelector(".style-fonts")?.remove();
if (exportedSvg) {
+2
View File
@@ -113,6 +113,8 @@
"esbuild-sass-plugin": "2.16.0",
"eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7",
"fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6",
"import-meta-loader": "1.1.0",
"mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1",
+67 -38
View File
@@ -39,6 +39,7 @@ import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts";
import type { Font } from "../fonts/ExcalidrawFont";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@@ -355,50 +356,14 @@ export const exportToSvg = async (
</clipPath>`;
}
const fontFamilies = elements.reduce((acc, element) => {
if (isTextElement(element)) {
acc.add(element.fontFamily);
}
return acc;
}, new Set<number>());
const fontFaces = opts?.skipInliningFonts
? []
: await Promise.all(
Array.from(fontFamilies).map(async (x) => {
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
if (!Array.isArray(fonts)) {
console.error(
`Couldn't find registered fonts for font-family "${x}"`,
Fonts.registered,
);
return;
}
if (metadata?.local) {
// don't inline local fonts
return;
}
return Promise.all(
fonts.map(
async (font) => `@font-face {
font-family: ${font.fontFace.family};
src: url(${await font.getContent()});
}`,
),
);
}),
);
const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
<defs>
<style class="style-fonts">
${fontFaces.flat().filter(Boolean).join("\n")}
${fontFaces.join("\n")}
</style>
${exportingFrameClipPath}
</defs>
@@ -469,3 +434,67 @@ export const getExportSize = (
return [width, height];
};
const getFontFaces = async (
elements: readonly ExcalidrawElement[],
): Promise<string[]> => {
const fontFamilies = new Set<number>();
const codePoints = new Set<number>();
for (const element of elements) {
if (!isTextElement(element)) {
continue;
}
fontFamilies.add(element.fontFamily);
// gather unique codepoints only when inlining fonts
for (const codePoint of Array.from(element.originalText, (u) =>
u.codePointAt(0),
)) {
if (codePoint) {
codePoints.add(codePoint);
}
}
}
const getSource = (font: Font) => {
try {
// retrieve font source as dataurl based on the used codepoints
return font.getContent(codePoints);
} catch {
// fallback to font source as a url
return font.urls[0].toString();
}
};
const fontFaces = await Promise.all(
Array.from(fontFamilies).map(async (x) => {
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
if (!Array.isArray(fonts)) {
console.error(
`Couldn't find registered fonts for font-family "${x}"`,
Fonts.registered,
);
return [];
}
if (metadata?.local) {
// don't inline local fonts
return [];
}
return Promise.all(
fonts.map(
async (font) => `@font-face {
font-family: ${font.fontFace.family};
src: url(${await getSource(font)});
}`,
),
);
}),
);
return fontFaces.flat();
};
+157
View File
@@ -0,0 +1,157 @@
import type { LineSegment } from "../utils";
import type { BoundingBox, Bounds } from "./element/bounds";
import { isBounds, isLineSegment } from "./element/typeChecks";
import type { Point } from "./types";
// The global data holder to collect the debug operations
declare global {
interface Window {
visualDebug?: {
data: DebugElement[][];
currentFrame?: number;
};
}
}
export type DebugElement = {
color: string;
data: LineSegment;
permanent: boolean;
};
export const debugDrawLine = (
segment: LineSegment | LineSegment[],
opts?: {
color?: string;
permanent?: boolean;
},
) => {
(isLineSegment(segment) ? [segment] : segment).forEach((data) =>
addToCurrentFrame({
color: opts?.color ?? "red",
data,
permanent: !!opts?.permanent,
}),
);
};
export const debugDrawPoint = (
point: Point,
opts?: {
color?: string;
permanent?: boolean;
fuzzy?: boolean;
},
) => {
const xOffset = opts?.fuzzy ? Math.random() * 3 : 0;
const yOffset = opts?.fuzzy ? Math.random() * 3 : 0;
debugDrawLine(
[
[point[0] + xOffset - 10, point[1] + yOffset - 10],
[point[0] + xOffset + 10, point[1] + yOffset + 10],
],
{
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
},
);
debugDrawLine(
[
[point[0] + xOffset - 10, point[1] + yOffset + 10],
[point[0] + xOffset + 10, point[1] + yOffset - 10],
],
{
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
},
);
};
export const debugDrawBoundingBox = (
box: BoundingBox | BoundingBox[],
opts?: {
color?: string;
permanent?: boolean;
},
) => {
(Array.isArray(box) ? box : [box]).forEach((bbox) =>
debugDrawLine(
[
[
[bbox.minX, bbox.minY],
[bbox.maxX, bbox.minY],
],
[
[bbox.maxX, bbox.minY],
[bbox.maxX, bbox.maxY],
],
[
[bbox.maxX, bbox.maxY],
[bbox.minX, bbox.maxY],
],
[
[bbox.minX, bbox.maxY],
[bbox.minX, bbox.minY],
],
],
{
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
},
),
);
};
export const debugDrawBounds = (
box: Bounds | Bounds[],
opts?: {
color: string;
permanent: boolean;
},
) => {
(isBounds(box) ? [box] : box).forEach((bbox) =>
debugDrawLine(
[
[
[bbox[0], bbox[1]],
[bbox[2], bbox[1]],
],
[
[bbox[2], bbox[1]],
[bbox[2], bbox[3]],
],
[
[bbox[2], bbox[3]],
[bbox[0], bbox[3]],
],
[
[bbox[0], bbox[3]],
[bbox[0], bbox[1]],
],
],
{
color: opts?.color ?? "green",
permanent: opts?.permanent,
},
),
);
};
export const debugCloseFrame = () => {
window.visualDebug?.data.push([]);
};
export const debugClear = () => {
if (window.visualDebug?.data) {
window.visualDebug.data = [];
}
};
const addToCurrentFrame = (element: DebugElement) => {
if (window.visualDebug?.data && window.visualDebug.data.length === 0) {
window.visualDebug.data[0] = [];
}
window.visualDebug?.data &&
window.visualDebug.data[window.visualDebug.data.length - 1].push(element);
};
-2
View File
@@ -21,8 +21,6 @@ interface ImportMetaEnv {
VITE_APP_FIREBASE_CONFIG: string;
// whether to enable Service Workers in development
VITE_APP_DEV_ENABLE_SW: string;
// whether to disable live reload / HMR. Usuaully what you want to do when
// debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD: string;
+75
View File
@@ -0,0 +1,75 @@
/**
* This script is used to convert the wasm modules into js modules, with the binary converted into base64 encoded strings.
*/
const fs = require("fs");
const path = require("path");
const wasmModules = [
{
pkg: `../node_modules/fonteditor-core`,
src: `./wasm/woff2.wasm`,
dest: `../packages/excalidraw/fonts/wasm/woff2.wasm.ts`,
},
{
pkg: `../node_modules/harfbuzzjs`,
src: `./wasm/hb-subset.wasm`,
dest: `../packages/excalidraw/fonts/wasm/hb-subset.wasm.ts`,
},
];
for (const { pkg, src, dest } of wasmModules) {
const packagePath = path.resolve(__dirname, pkg, "package.json");
const licensePath = path.resolve(__dirname, pkg, "LICENSE");
const sourcePath = path.resolve(__dirname, src);
const destPath = path.resolve(__dirname, dest);
const {
name,
version,
author,
license,
authors,
licenses,
} = require(packagePath);
const licenseContent = fs.readFileSync(licensePath, "utf-8") || "";
const base64 = fs.readFileSync(sourcePath, "base64");
const content = `// GENERATED CODE -- DO NOT EDIT!
/* eslint-disable prettier/prettier */
// @ts-nocheck
/**
* The following wasm module is generated with \`scripts/buildWasm.js\` and encoded as base64.
*
* The source of this content is taken from the package "${name}", which contains the following metadata:
*
* @author ${author || JSON.stringify(authors)}
* @license ${license || JSON.stringify(licenses)}
* @version ${version}
${licenseContent}
*/
// faster atob alternative - https://github.com/evanw/esbuild/issues/1534#issuecomment-902738399
const __toBinary = /* @__PURE__ */ (() => {
const table = new Uint8Array(128);
for (let i = 0; i < 64; i++)
{table[i < 26 ? i + 65 : i < 52 ? i + 71 : i < 62 ? i - 4 : i * 4 - 205] = i;}
return (base64) => {
const n = base64.length; const bytes = new Uint8Array((n - (base64[n - 1] == "=") - (base64[n - 2] == "=")) * 3 / 4 | 0);
for (let i2 = 0, j = 0; i2 < n; ) {
const c0 = table[base64.charCodeAt(i2++)]; const c1 = table[base64.charCodeAt(i2++)];
const c2 = table[base64.charCodeAt(i2++)]; const c3 = table[base64.charCodeAt(i2++)];
bytes[j++] = c0 << 2 | c1 >> 4;
bytes[j++] = c1 << 4 | c2 >> 2;
bytes[j++] = c2 << 6 | c3;
}
return bytes;
};
})();
export default __toBinary(\`${base64}\`);
`;
fs.writeFileSync(destPath, content);
}
BIN
View File
Binary file not shown.
Binary file not shown.
+15 -3
View File
@@ -6194,6 +6194,13 @@ fonteditor-core@2.4.0:
dependencies:
"@xmldom/xmldom" "^0.8.3"
fonteditor-core@2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/fonteditor-core/-/fonteditor-core-2.4.1.tgz#ff4b3cd04b50f98026bedad353d0ef6692464bc9"
integrity sha512-nKDDt6kBQGq665tQO5tCRQUClJG/2MAF9YT1eKHl+I4NasdSb6DgXrv/gMjNxjo9NyaVEv9KU9VZxLHMstN1wg==
dependencies:
"@xmldom/xmldom" "^0.8.3"
for-each@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
@@ -6457,6 +6464,11 @@ hachure-fill@^0.5.2:
resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc"
integrity sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==
harfbuzzjs@0.3.6:
version "0.3.6"
resolved "https://registry.yarnpkg.com/harfbuzzjs/-/harfbuzzjs-0.3.6.tgz#97865c861aa7734af5bd1904570712e9d753fda9"
integrity sha512-dzf7y6NS8fiAIvPAL/VKwY8wx2HCzUB0vUfOo6h1J5UilFEEf7iYqFsvgwjHwvM3whbjfOMadNvQekU3KuRnWQ==
has-ansi@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@@ -7820,9 +7832,9 @@ micromark@^3.0.0:
uvu "^0.5.0"
micromatch@^4.0.0, micromatch@^4.0.4:
version "4.0.7"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.7.tgz#33e8190d9fe474a9895525f5618eee136d46c2e5"
integrity sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies:
braces "^3.0.3"
picomatch "^2.3.1"