Compare commits

...

2 Commits

Author SHA1 Message Date
dwelle
fa9631617f wip: get exact bounding box from bitmap when exporting w/o padding 2022-11-03 18:34:17 +01:00
dwelle
0314e81396 feat: support toggling export padding 2022-11-03 17:31:41 +01:00
8 changed files with 189 additions and 19 deletions

View File

@ -89,6 +89,28 @@ export const actionChangeExportScale = register({
}, },
}); });
export const actionChangeExportPadding = register({
name: "changeExportPadding",
trackEvent: { category: "export", action: "togglePadding" },
perform: (_elements, appState, value) => {
return {
appState: {
...appState,
exportPadding: value ? DEFAULT_EXPORT_PADDING : 0,
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={!!appState.exportPadding}
onChange={(checked) => updateData(checked)}
>
{"Padding"}
</CheckboxItem>
),
});
export const actionChangeExportBackground = register({ export const actionChangeExportBackground = register({
name: "changeExportBackground", name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" }, trackEvent: { category: "export", action: "toggleBackground" },

View File

@ -68,6 +68,7 @@ export type ActionName =
| "finalize" | "finalize"
| "changeProjectName" | "changeProjectName"
| "changeExportBackground" | "changeExportBackground"
| "changeExportPadding"
| "changeExportEmbedScene" | "changeExportEmbedScene"
| "changeExportScale" | "changeExportScale"
| "saveToActiveFile" | "saveToActiveFile"

View File

@ -1,5 +1,6 @@
import oc from "open-color"; import oc from "open-color";
import { import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
@ -55,6 +56,7 @@ export const getDefaultAppState = (): Omit<
exportScale: defaultExportScale, exportScale: defaultExportScale,
exportEmbedScene: false, exportEmbedScene: false,
exportWithDarkMode: false, exportWithDarkMode: false,
exportPadding: DEFAULT_EXPORT_PADDING,
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
@ -145,6 +147,7 @@ const APP_STATE_STORAGE_CONF = (<
exportBackground: { browser: true, export: false, server: false }, exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false }, exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false }, exportScale: { browser: true, export: false, server: false },
exportPadding: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false }, exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false }, fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },

View File

@ -79,7 +79,6 @@ const ImageExportModal = ({
elements, elements,
appState, appState,
files, files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
@ -88,7 +87,6 @@ const ImageExportModal = ({
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager; actionManager: ActionManager;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
@ -116,7 +114,7 @@ const ImageExportModal = ({
exportToCanvas(exportedElements, appState, files, { exportToCanvas(exportedElements, appState, files, {
exportBackground, exportBackground,
viewBackgroundColor, viewBackgroundColor,
exportPadding, exportPadding: appState.exportPadding,
}) })
.then((canvas) => { .then((canvas) => {
// if converting to blob fails, there's some problem that will // if converting to blob fails, there's some problem that will
@ -134,7 +132,6 @@ const ImageExportModal = ({
files, files,
exportedElements, exportedElements,
exportBackground, exportBackground,
exportPadding,
viewBackgroundColor, viewBackgroundColor,
]); ]);
@ -151,8 +148,10 @@ const ImageExportModal = ({
// dunno why this is needed, but when the items wrap it creates // dunno why this is needed, but when the items wrap it creates
// an overflow // an overflow
overflow: "hidden", overflow: "hidden",
gap: ".6rem",
}} }}
> >
{actionManager.renderAction("changeExportPadding")}
{actionManager.renderAction("changeExportBackground")} {actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && ( {someElementIsSelected && (
<CheckboxItem <CheckboxItem
@ -221,7 +220,6 @@ export const ImageExportDialog = ({
appState, appState,
setAppState, setAppState,
files, files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
@ -231,7 +229,6 @@ export const ImageExportDialog = ({
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager; actionManager: ActionManager;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
@ -249,7 +246,6 @@ export const ImageExportDialog = ({
elements={elements} elements={elements}
appState={appState} appState={appState}
files={files} files={files}
exportPadding={exportPadding}
actionManager={actionManager} actionManager={actionManager}
onExportToPng={onExportToPng} onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg} onExportToSvg={onExportToSvg}

View File

@ -144,6 +144,7 @@ const LayerUI = ({
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
name: appState.name, name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
exportPadding: appState.exportPadding,
}, },
) )
.catch(muteFSAbortError) .catch(muteFSAbortError)

View File

@ -492,7 +492,7 @@ export const getElementBounds = (
export const getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): [number, number, number, number] => { ): [minX: number, minY: number, maxX: number, maxY: number] => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }

View File

@ -14,6 +14,102 @@ import {
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
const getExactBoundingBox = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: {
exportBackground: boolean;
exportPadding?: number;
exportScale?: number;
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
},
files: BinaryFiles,
): Promise<
[offsetLeft: number, offsetTop: number, width: number, height: number]
> => {
const padding = DEFAULT_EXPORT_PADDING;
// const padding = 0;
const [minX, minY, width, height] = getApproximateCanvasSize(
elements,
padding,
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const { imageCache } = await updateImageCache({
imageCache: new Map(),
fileIds: getInitializedImageElements(elements).map(
(element) => element.fileId,
),
files,
});
const defaultAppState = getDefaultAppState();
renderScene({
elements,
// @ts-ignore
appState,
scale: 1,
rc: rough.canvas(canvas),
canvas,
renderConfig: {
viewBackgroundColor: null,
scrollX: -minX + padding,
scrollY: -minY + padding,
zoom: defaultAppState.zoom,
remotePointerViewportCoords: {},
remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false,
remotePointerUsernames: {},
remotePointerUserStates: {},
theme: "light",
imageCache,
renderScrollbars: false,
renderSelection: false,
renderGrid: false,
isExporting: true,
},
});
const ctx = canvas.getContext("2d")!;
const { data } = ctx.getImageData(0, 0, width, height);
let _minX = Infinity;
let _minY = Infinity;
let _maxX = -Infinity;
let _maxY = -Infinity;
const rows = [];
let row: number[][] = [];
for (let i = 0; i < data.length - 1; i = i + 4) {
if (i && i % (width * 4) === 0) {
rows.push(row);
row = [];
}
const pixel = [data[i], data[i + 1], data[i + 2], data[i + 3]];
row.push(pixel);
}
for (const [y, row] of rows.entries()) {
for (const [x, pixel] of row.entries()) {
if (pixel[3] > 0) {
_minX = Math.min(_minX, x);
_minY = Math.min(_minY, y);
_maxX = Math.max(_maxX, x);
_maxY = Math.max(_maxY, y);
}
}
}
const offsetLeft = padding - _minX;
const offsetTop = padding - _minY;
return [offsetLeft, offsetTop, _maxX - _minX, _maxY - _minY];
};
export const exportToCanvas = async ( export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
@ -37,7 +133,12 @@ export const exportToCanvas = async (
return { canvas, scale: appState.exportScale }; return { canvas, scale: appState.exportScale };
}, },
) => { ) => {
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding); const [scrollX, scrollY, width, height] = await getCanvasSize(
elements,
appState,
files,
exportPadding,
);
const { canvas, scale = 1 } = createCanvas(width, height); const { canvas, scale = 1 } = createCanvas(width, height);
@ -59,8 +160,8 @@ export const exportToCanvas = async (
canvas, canvas,
renderConfig: { renderConfig: {
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + exportPadding, scrollX,
scrollY: -minY + exportPadding, scrollY,
zoom: defaultAppState.zoom, zoom: defaultAppState.zoom,
remotePointerViewportCoords: {}, remotePointerViewportCoords: {},
remoteSelectedElementIds: {}, remoteSelectedElementIds: {},
@ -109,7 +210,12 @@ export const exportToSvg = async (
console.error(error); console.error(error);
} }
} }
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding); const [minX, minY, width, height] = await getCanvasSize(
elements,
appState,
files || {},
exportPadding,
);
// initialize SVG root // initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg"); const svgRoot = document.createElementNS(SVG_NS, "svg");
@ -172,26 +278,66 @@ export const exportToSvg = async (
return svgRoot; return svgRoot;
}; };
// calculate smallest area to fit the contents in const getApproximateCanvasSize = (
const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
): [number, number, number, number] => { ): [number, number, number, number] => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const bounds = getCommonBounds(elements);
const minX = Math.floor(bounds[0]);
const minY = Math.floor(bounds[1]);
const maxX = Math.ceil(bounds[2]);
const maxY = Math.ceil(bounds[3]);
const width = distance(minX, maxX) + exportPadding * 2; const width = distance(minX, maxX) + exportPadding * 2;
const height = distance(minY, maxY) + exportPadding + exportPadding; const height =
Math.ceil(distance(minY, maxY)) + exportPadding + exportPadding;
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };
// calculate smallest area to fit the contents in
const getCanvasSize = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: {
exportBackground: boolean;
exportPadding?: number;
exportScale?: number;
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
},
files: BinaryFiles,
exportPadding: number,
): Promise<[number, number, number, number]> => {
if (exportPadding) {
const [minX, minY, width, height] = getApproximateCanvasSize(
elements,
exportPadding,
);
return [-minX + exportPadding, -minY + exportPadding, width, height];
} else {
const [minX, minY] = getApproximateCanvasSize(elements, exportPadding);
const [offsetLeft, offsetRight, width, height] = await getExactBoundingBox(
elements,
appState,
files,
);
return [-minX + offsetLeft, -minY + offsetRight, width, height];
}
};
export const getExportSize = ( export const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
scale: number, scale: number,
): [number, number] => { ): [number, number] => {
const [, , width, height] = getCanvasSize(elements, exportPadding).map( const [, , width, height] = getApproximateCanvasSize(
(dimension) => Math.trunc(dimension * scale), elements,
); exportPadding,
).map((dimension) => Math.trunc(dimension * scale));
return [width, height]; return [width, height];
}; };

View File

@ -113,6 +113,7 @@ export type AppState = {
exportEmbedScene: boolean; exportEmbedScene: boolean;
exportWithDarkMode: boolean; exportWithDarkMode: boolean;
exportScale: number; exportScale: number;
exportPadding: number;
currentItemStrokeColor: string; currentItemStrokeColor: string;
currentItemBackgroundColor: string; currentItemBackgroundColor: string;
currentItemFillStyle: ExcalidrawElement["fillStyle"]; currentItemFillStyle: ExcalidrawElement["fillStyle"];