Skip to content

Commit

Permalink
feat: Add new context menus to export visually selected range
Browse files Browse the repository at this point in the history
  • Loading branch information
miyanokomiya committed Sep 28, 2024
1 parent 62adc8a commit 6a19d04
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 23 deletions.
14 changes: 8 additions & 6 deletions src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,14 @@ const ContextItem: React.FC<ContextItemProps> = ({ item, dropdownKey, onClickIte
<div className="relative">
<div>
<ListButton onClick={handleClick}>
<AppText portal={true}>{item.label}</AppText>
<img
className={"ml-2 w-3 h-3 transition-transform " + (dropdownKey === item.key ? "rotate-90" : "-rotate-90")}
src={iconDropdown}
alt=""
/>
<div className="flex items-center justify-between gap-2 w-full">
<AppText portal={true}>{item.label}</AppText>
<img
className={"w-3 h-3 transition-transform " + (dropdownKey === item.key ? "rotate-90" : "-rotate-90")}
src={iconDropdown}
alt=""
/>
</div>
</ListButton>
</div>
{dropdownKey === item.key ? (
Expand Down
143 changes: 126 additions & 17 deletions src/composables/states/appCanvas/contextMenuItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ import { isGroupShape } from "../../../shapes/group";
import { mapFilter, mapReduce, splitList } from "../../../utils/commons";
import { mergeEntityPatchInfo, normalizeEntityPatchInfo } from "../../../utils/entities";
import { FOLLY_SVG_PREFIX } from "../../../shapes/utils/shapeTemplateUtil";
import { newImageBuilder, newSVGImageBuilder } from "../../imageBuilder";
import { canGroupShapes, getAllShapeRangeWithinComposite, newShapeComposite } from "../../shapeComposite";
import { ImageBuilder, newImageBuilder, newSVGImageBuilder, SVGImageBuilder } from "../../imageBuilder";
import {
canGroupShapes,
getAllShapeRangeWithinComposite,
newShapeComposite,
ShapeComposite,
} from "../../shapeComposite";
import { getPatchByLayouts } from "../../shapeLayoutHandler";
import { newShapeRenderer } from "../../shapeRenderer";
import { newShapeSVGRenderer } from "../../shapeSVGRenderer";
import { TransitionValue } from "../core";
import { ContextMenuItem } from "../types";
import { AppCanvasStateContext, ContextMenuItemEvent } from "./core";
import { IRectangle } from "okageo";

export const CONTEXT_MENU_ITEM_SRC = {
DELETE_SHAPE: {
Expand Down Expand Up @@ -68,6 +74,20 @@ export const CONTEXT_MENU_ITEM_SRC = {
},
],
},
EXPORT_SELECTED_RANGE: {
label: "Export selected range as",
key: "EXPORT_SELECTED_RANGE",
children: [
{
label: "PNG",
key: "EXPORT_RANGE_AS_PNG",
},
{
label: "SVG",
key: "EXPORT_RANGE_AS_SVG",
},
],
},

DELETE_LINE_VERTEX: {
label: "Delete vertex",
Expand All @@ -80,6 +100,7 @@ export const CONTEXT_MENU_ITEM_SRC = {
const CONTEXT_MENU_COPY_SHAPE_ITEMS: ContextMenuItem[] = [
CONTEXT_MENU_ITEM_SRC.COPY_AS_PNG,
CONTEXT_MENU_ITEM_SRC.EXPORT_SELECTED_SHAPES,
CONTEXT_MENU_ITEM_SRC.EXPORT_SELECTED_RANGE,
];

export function getMenuItemsForSelectedShapes(
Expand Down Expand Up @@ -164,6 +185,12 @@ export function handleContextItemEvent(
case "EXPORT_AS_FOLLY_SVG":
exportShapesAsSVG(ctx, true);
return;
case "EXPORT_RANGE_AS_PNG":
exportRangeAsPNG(ctx);
return;
case "EXPORT_RANGE_AS_SVG":
exportRangeAsSVG(ctx);
return;
}
}

Expand All @@ -187,7 +214,28 @@ async function copyShapesAsPNG(ctx: AppCanvasStateContext): Promise<void> {
}

function exportShapesAsPNG(ctx: AppCanvasStateContext) {
const builder = getImageBuilderForSelectedShapes(ctx);
if (!ctx.getLastSelectedShapeId()) {
ctx.showToastMessage({
text: "No shape is selected",
type: "error",
});
return;
}
exportAsPNG(ctx, getImageBuilderForSelectedShapes(ctx));
}

function exportRangeAsPNG(ctx: AppCanvasStateContext) {
if (!ctx.getLastSelectedShapeId()) {
ctx.showToastMessage({
text: "No shape is selected",
type: "error",
});
return;
}
exportAsPNG(ctx, getImageBuilderForSelectedRange(ctx));
}

function exportAsPNG(ctx: AppCanvasStateContext, builder: ImageBuilder) {
try {
saveFileInWeb(builder.toDataURL(), "shapes.png");
} catch (e) {
Expand All @@ -200,42 +248,78 @@ function exportShapesAsPNG(ctx: AppCanvasStateContext) {
}

function getImageBuilderForSelectedShapes(ctx: AppCanvasStateContext) {
const targetShapes = ctx.getShapeComposite().getAllBranchMergedShapes(Object.keys(ctx.getSelectedShapeIdMap()));
const info = getExportParamsForSelectedShapes(ctx);
return getImageBuilderForShapesWithRange(ctx, info.targetShapeComposite, info.range);
}

function getImageBuilderForSelectedRange(ctx: AppCanvasStateContext) {
const info = getExportParamsForSelectedRange(ctx);
return getImageBuilderForShapesWithRange(ctx, info.targetShapeComposite, info.range);
}

function getExportParamsForSelectedShapes(ctx: AppCanvasStateContext) {
const shapeComposite = ctx.getShapeComposite();
const targetShapes = shapeComposite.getAllBranchMergedShapes(Object.keys(ctx.getSelectedShapeIdMap()));
const targetShapeComposite = newShapeComposite({ shapes: targetShapes, getStruct: ctx.getShapeStruct });
// Get optimal exporting range for shapes.
// This range may differ from visually selected range due to the optimization.
const range = getAllShapeRangeWithinComposite(targetShapeComposite, true);
return { targetShapeComposite, range };
}

function getExportParamsForSelectedRange(ctx: AppCanvasStateContext) {
const shapeComposite = ctx.getShapeComposite();
const srcShapes = shapeComposite.getAllBranchMergedShapes(Object.keys(ctx.getSelectedShapeIdMap()));
// Get currently selected range.
// Unlike "getExportParamsForSelectedShapes", this function prioritizes visually selected range.
const range = shapeComposite.getWrapperRectForShapes(srcShapes, true);
const targetShapes = shapeComposite.getShapesOverlappingRect(shapeComposite.shapes, range);
const targetShapeComposite = newShapeComposite({ shapes: targetShapes, getStruct: ctx.getShapeStruct });
return { targetShapeComposite, range };
}

function getImageBuilderForShapesWithRange(
ctx: AppCanvasStateContext,
targetShapeComposite: ShapeComposite,
range: IRectangle,
) {
const renderer = newShapeRenderer({
shapeComposite: targetShapeComposite,
getDocumentMap: ctx.getDocumentMap,
imageStore: ctx.getImageStore(),
});

const range = getAllShapeRangeWithinComposite(targetShapeComposite, true);
return newImageBuilder({ render: renderer.render, range });
}

async function exportShapesAsSVG(ctx: AppCanvasStateContext, withMeta = false): Promise<void> {
const targetShapes = ctx.getShapeComposite().getAllBranchMergedShapes(Object.keys(ctx.getSelectedShapeIdMap()));
if (targetShapes.length === 0) {
if (!ctx.getLastSelectedShapeId()) {
ctx.showToastMessage({
text: "No shape is selected",
type: "error",
});
return;
}

const targetShapeComposite = newShapeComposite({ shapes: targetShapes, getStruct: ctx.getShapeStruct });
const renderer = newShapeSVGRenderer({
shapeComposite: targetShapeComposite,
getDocumentMap: ctx.getDocumentMap,
imageStore: ctx.getImageStore(),
assetAPI: ctx.assetAPI,
});
const range = getAllShapeRangeWithinComposite(targetShapeComposite, true);
await exportAsSVG(ctx, getSVGBuilderForShapes(ctx, withMeta), withMeta ? `shapes${FOLLY_SVG_PREFIX}` : "shapes.svg");
}

async function exportRangeAsSVG(ctx: AppCanvasStateContext): Promise<void> {
if (!ctx.getLastSelectedShapeId()) {
ctx.showToastMessage({
text: "No shape is selected",
type: "error",
});
return;
}

await exportAsSVG(ctx, getSVGBuilderForRange(ctx), "shapes.svg");
}

async function exportAsSVG(ctx: AppCanvasStateContext, builder: SVGImageBuilder, name: string): Promise<void> {
try {
const builder = newSVGImageBuilder({ render: withMeta ? renderer.renderWithMeta : renderer.render, range });
const dataURL = await builder.toDataURL();
saveFileInWeb(dataURL, withMeta ? `shapes${FOLLY_SVG_PREFIX}` : "shapes.svg");
saveFileInWeb(dataURL, name);
} catch (e: any) {
ctx.showToastMessage({
text: `Failed to create image. ${e.message}`,
Expand All @@ -245,6 +329,31 @@ async function exportShapesAsSVG(ctx: AppCanvasStateContext, withMeta = false):
}
}

function getSVGBuilderForShapes(ctx: AppCanvasStateContext, withMeta = false) {
const info = getExportParamsForSelectedShapes(ctx);
return getSVGBuilderForShapesWithRange(ctx, info.targetShapeComposite, info.range, withMeta);
}

function getSVGBuilderForRange(ctx: AppCanvasStateContext, withMeta = false) {
const info = getExportParamsForSelectedRange(ctx);
return getSVGBuilderForShapesWithRange(ctx, info.targetShapeComposite, info.range, withMeta);
}

function getSVGBuilderForShapesWithRange(
ctx: AppCanvasStateContext,
targetShapeComposite: ShapeComposite,
range: IRectangle,
withMeta = false,
) {
const renderer = newShapeSVGRenderer({
shapeComposite: targetShapeComposite,
getDocumentMap: ctx.getDocumentMap,
imageStore: ctx.getImageStore(),
assetAPI: ctx.assetAPI,
});
return newSVGImageBuilder({ render: withMeta ? renderer.renderWithMeta : renderer.render, range });
}

function saveFileInWeb(file: string, filename: string) {
const a = document.createElement("a");
a.href = file;
Expand Down

0 comments on commit 6a19d04

Please sign in to comment.