feat(core): support copy as image in electron app (#8939)

Close issue [AF-1785](https://linear.app/affine-design/issue/AF-1785).

### What changed?
- Support copy as image in electron app:
  -  Select the whole mindmap if any of the mindmap nodes is selected.
  -  Hide unselected overlap elements before taking a snapshot.
  -  Fit the selected elements to the screen.
  -  Add CSS style to hide irrelevant dom nodes, like widgets, whiteboard background and so on.
     - Due to the usage of Shadow Dom in our code, not all node styles can be controlled. Thus this PR use `-2px` padding for `affine:frame` snapshots.
  - Using electron `capturePage` API to take a snapshot of selected elements.

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/sJGviKxfE3Ap685cl5bj/c1b7b772-ddf8-4a85-b670-e96af7bd5cc0.mov">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/sJGviKxfE3Ap685cl5bj/c1b7b772-ddf8-4a85-b670-e96af7bd5cc0.mov">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/c1b7b772-ddf8-4a85-b670-e96af7bd5cc0.mov">录屏2024-11-27 16.11.03.mov</video>
This commit is contained in:
akumatus
2024-11-28 03:58:04 +00:00
parent f780316f8b
commit c95e6ec518
10 changed files with 279 additions and 13 deletions

View File

@@ -0,0 +1,234 @@
import { notify } from '@affine/component';
import {
isMindmapChild,
isMindMapRoot,
} from '@affine/core/blocksuite/presets/ai/utils/edgeless';
import { EditorService } from '@affine/core/modules/editor';
import { apis } from '@affine/electron-api';
import { I18n } from '@affine/i18n';
import type { BlockStdScope } from '@blocksuite/affine/block-std';
import {
type GfxBlockElementModel,
type GfxModel,
GfxPrimitiveElementModel,
isGfxGroupCompatibleModel,
} from '@blocksuite/affine/block-std/gfx';
import type {
EdgelessRootService,
MenuContext,
} from '@blocksuite/affine/blocks';
import { Bound, getCommonBound } from '@blocksuite/affine/global/utils';
import { CopyAsImgaeIcon } from '@blocksuite/icons/lit';
import type { FrameworkProvider } from '@toeverything/infra';
const snapshotStyle = `
affine-edgeless-root .widgets-container,
.copy-as-image-transparent {
opacity: 0;
}
.edgeless-background {
background-image: none;
}
`;
function getSelectedRect() {
const selected = document
.querySelector('edgeless-selected-rect')
?.shadowRoot?.querySelector('.affine-edgeless-selected-rect');
if (!selected) {
throw new Error('Missing edgeless selected rect');
}
return selected.getBoundingClientRect();
}
function expandBound(bound: Bound, margin: number) {
const x = bound.x - margin;
const y = bound.y - margin;
const w = bound.w + margin * 2;
const h = bound.h + margin * 2;
return new Bound(x, y, w, h);
}
function isOverlap(target: Bound, source: Bound) {
const { x, y, w, h } = source;
const left = target.x;
const top = target.y;
const right = target.x + target.w;
const bottom = target.y + target.h;
return x < right && y < bottom && x + w > left && y + h > top;
}
function isInside(target: Bound, source: Bound) {
const { x, y, w, h } = source;
const left = target.x;
const top = target.y;
const right = target.x + target.w;
const bottom = target.y + target.h;
return x >= left && y >= top && x + w <= right && y + h <= bottom;
}
function hideEdgelessElements(elements: GfxModel[], std: BlockStdScope) {
elements.forEach(ele => {
if (ele instanceof GfxPrimitiveElementModel) {
(ele as any).lastOpacity = ele.opacity;
ele.opacity = 0;
} else {
const block = std.view.getBlock(ele.id);
if (!block) return;
block.classList.add('copy-as-image-transparent');
}
});
}
function showEdgelessElements(elements: GfxModel[], std: BlockStdScope) {
elements.forEach(ele => {
if (ele instanceof GfxPrimitiveElementModel) {
ele.opacity = (ele as any).lastOpacity;
delete (ele as any).lastOpacity;
} else {
const block = std.view.getBlock(ele.id);
if (!block) return;
block.classList.remove('copy-as-image-transparent');
}
});
}
function withDescendantElements(elements: GfxModel[]) {
const set = new Set<GfxModel>();
elements.forEach(element => {
if (set.has(element)) return;
set.add(element);
if (isGfxGroupCompatibleModel(element)) {
element.descendantElements.map(descendant => set.add(descendant));
}
});
return [...set];
}
const MARGIN = 20;
export function createCopyAsPngMenuItem(framework: FrameworkProvider) {
return {
icon: CopyAsImgaeIcon({ width: '20', height: '20' }),
label: 'Copy as Image',
type: 'copy-as-image',
when: (ctx: MenuContext) => {
if (ctx.isEmpty()) return false;
const { editor } = framework.get(EditorService);
const mode = editor.mode$.value;
return mode === 'edgeless';
},
action: async (ctx: MenuContext) => {
if (!apis) {
notify.error({
title: I18n.t('com.affine.copy.asImage.notAvailable.title'),
message: I18n.t('com.affine.copy.asImage.notAvailable.message'),
action: {
label: I18n.t('com.affine.copy.asImage.notAvailable.action'),
onClick: () => {
window.open('https://affine.pro/download');
},
},
});
return;
}
const service =
ctx.host.std.getService<EdgelessRootService>('affine:page');
if (!service) return;
let selected = service.selection.selectedElements;
// select mindmap if root node selected
const maybeMindmap = selected[0];
const mindmapId = maybeMindmap.group?.id;
if (
selected.length === 1 &&
mindmapId &&
(isMindMapRoot(maybeMindmap) || isMindmapChild(maybeMindmap))
) {
service.gfx.selection.set({ elements: [mindmapId] });
}
// select bound
selected = service.selection.selectedElements;
const elements = withDescendantElements(selected);
const bounds = elements.map(element => Bound.deserialize(element.xywh));
const bound = getCommonBound(bounds);
if (!bound) return;
const { zoom } = service.viewport;
const exBound = expandBound(bound, MARGIN * zoom);
// fit to screen
if (
!isInside(service.viewport.viewportBounds, exBound) ||
service.viewport.zoom < 1
) {
service.viewport.setViewportByBound(bound, [20, 20, 20, 20], false);
if (service.viewport.zoom > 1) {
service.viewport.setZoom(1);
}
}
// hide unselected overlap elements
const overlapElements = service.gfx.gfxElements.filter(ele => {
const eleBound = Bound.deserialize(ele.xywh);
const exEleBound = expandBound(eleBound, MARGIN * zoom);
const isSelected = elements.includes(ele);
return !isSelected && isOverlap(exBound, exEleBound);
});
hideEdgelessElements(overlapElements, ctx.host.std);
// add css style
const styleEle = document.createElement('style');
styleEle.innerHTML = snapshotStyle;
document.head.append(styleEle);
// capture image
setTimeout(() => {
if (!apis) return;
try {
const domRect = getSelectedRect();
const { zoom } = service.viewport;
const isFrameSelected =
selected.length === 1 &&
(selected[0] as GfxBlockElementModel).flavour === 'affine:frame';
const margin = isFrameSelected ? -2 : MARGIN * zoom;
service.selection.clear();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
apis.ui
.captureArea({
x: domRect.left - margin,
y: domRect.top - margin,
width: domRect.width + margin * 2,
height: domRect.height + margin * 2,
})
.then(() => {
notify.success({
title: I18n.t('com.affine.copy.asImage.success'),
});
})
.catch(e => {
notify.error({
title: I18n.t('com.affine.copy.asImage.failed'),
message: String(e),
});
})
.finally(() => {
styleEle.remove();
showEdgelessElements(overlapElements, ctx.host.std);
});
} catch (e) {
styleEle.remove();
showEdgelessElements(overlapElements, ctx.host.std);
notify.error({
title: I18n.t('com.affine.copy.asImage.failed'),
message: String(e),
});
}
}, 100);
},
};
}

View File

@@ -12,11 +12,13 @@ import type {
GfxBlockElementModel,
GfxPrimitiveElementModel,
} from '@blocksuite/affine/block-std/gfx';
import type { MenuContext } from '@blocksuite/affine/blocks';
import { type MenuContext } from '@blocksuite/affine/blocks';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { LinkIcon } from '@blocksuite/icons/lit';
import type { FrameworkProvider } from '@toeverything/infra';
import { createCopyAsPngMenuItem } from './copy-as-image';
export function createToolbarMoreMenuConfig(framework: FrameworkProvider) {
return {
configure: <T extends MenuContext>(groups: MenuItemGroup<T>[]) => {
@@ -41,6 +43,12 @@ export function createToolbarMoreMenuConfig(framework: FrameworkProvider) {
0,
createCopyLinkToBlockMenuItem(framework)
);
clipboardGroup.items.splice(
copyIndex + 1,
0,
createCopyAsPngMenuItem(framework)
);
}
return groups;