Skip to content

Commit

Permalink
feat: support copy as png
Browse files Browse the repository at this point in the history
  • Loading branch information
akumatus committed Nov 27, 2024
1 parent f239857 commit a17aaa5
Show file tree
Hide file tree
Showing 15 changed files with 465 additions and 204 deletions.
2 changes: 1 addition & 1 deletion packages/common/env/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/affine": "0.18.0",
"@blocksuite/affine": "0.0.0-canary-20241127081342",
"vitest": "2.1.4"
},
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/common/infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/affine": "0.18.0",
"@blocksuite/affine": "0.0.0-canary-20241127081342",
"@datastructures-js/binary-search-tree": "^5.3.2",
"eventemitter2": "^6.4.9",
"foxact": "^0.2.33",
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/apps/android/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/i18n": "workspace:*",
"@blocksuite/affine": "0.18.0",
"@blocksuite/icons": "^2.1.70",
"@blocksuite/affine": "0.0.0-canary-20241127081342",
"@blocksuite/icons": "2.1.71",
"@capacitor/android": "^6.1.2",
"@capacitor/core": "^6.1.2",
"@sentry/react": "^8.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@affine/core": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@blocksuite/affine": "0.18.0",
"@blocksuite/affine": "0.0.0-canary-20241127081342",
"@electron-forge/cli": "^7.3.0",
"@electron-forge/core": "^7.3.0",
"@electron-forge/core-utils": "^7.3.0",
Expand Down
21 changes: 20 additions & 1 deletion packages/frontend/apps/electron/src/main/ui/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, nativeTheme, shell } from 'electron';
import { app, clipboard, nativeImage, nativeTheme, shell } from 'electron';
import { getLinkPreview } from 'link-preview-js';

import { isMacOS } from '../../shared/utils';
Expand Down Expand Up @@ -232,4 +232,23 @@ export const uiHandlers = {
e.sender.session.setSpellCheckerLanguages([language, 'en-US']);
}
},
captureArea: async (e, { x, y, width, height }: Electron.Rectangle) => {
const image = await e.sender.capturePage({
x: Math.floor(x),
y: Math.floor(y),
width: Math.floor(width),
height: Math.floor(height),
});

if (image.isEmpty()) {
throw new Error('Image is empty or invalid');
}

const buffer = image.toPNG();
if (!buffer || !buffer.length) {
throw new Error('Failed to generate PNG buffer from image');
}

clipboard.writeImage(nativeImage.createFromBuffer(buffer));
},
} satisfies NamespaceHandlers;
4 changes: 2 additions & 2 deletions packages/frontend/apps/ios/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/i18n": "workspace:*",
"@blocksuite/affine": "0.18.0",
"@blocksuite/icons": "^2.1.70",
"@blocksuite/affine": "0.0.0-canary-20241127081342",
"@blocksuite/icons": "2.1.71",
"@capacitor/app": "^6.0.1",
"@capacitor/browser": "^6.0.3",
"@capacitor/core": "^6.1.2",
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/i18n": "workspace:*",
"@blocksuite/affine": "0.18.0",
"@blocksuite/icons": "^2.1.70",
"@blocksuite/affine": "0.0.0-canary-20241127081342",
"@blocksuite/icons": "2.1.71",
"@sentry/react": "^8.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
8 changes: 4 additions & 4 deletions packages/frontend/component/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"peerDependencies": {
"@blocksuite/affine": "*",
"@blocksuite/icons": "2.1.68"
"@blocksuite/icons": "2.1.71"
},
"dependencies": {
"@affine/cli": "workspace:*",
Expand All @@ -24,7 +24,7 @@
"@affine/i18n": "workspace:*",
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@blocksuite/icons": "2.1.70",
"@blocksuite/icons": "2.1.71",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@radix-ui/react-avatar": "^1.0.4",
Expand Down Expand Up @@ -63,8 +63,8 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@blocksuite/affine": "0.18.0",
"@blocksuite/icons": "2.1.70",
"@blocksuite/affine": "0.0.0-canary-20241127081342",
"@blocksuite/icons": "2.1.71",
"@chromatic-com/storybook": "^3.0.0",
"@storybook/addon-essentials": "^8.2.9",
"@storybook/addon-interactions": "^8.2.9",
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"@affine/i18n": "workspace:*",
"@affine/templates": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "0.18.0",
"@blocksuite/icons": "2.1.70",
"@blocksuite/affine": "0.0.0-canary-20241127081342",
"@blocksuite/icons": "2.1.71",
"@capacitor/app": "^6.0.1",
"@capacitor/browser": "^6.0.3",
"@dnd-kit/core": "^6.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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 = `
edgeless-selected-rect,
edgeless-element-toolbar-widget,
affine-drag-handle-widget,
edgeless-toolbar-widget,
.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);

// 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);

// 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);
}
}

// add css style
const styleEle = document.createElement('style');
styleEle.innerHTML = snapshotStyle;
document.head.append(styleEle);

// capture image
setTimeout(() => {
if (!apis) return;
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);
});
}, 100);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>[]) => {
Expand All @@ -41,6 +43,12 @@ export function createToolbarMoreMenuConfig(framework: FrameworkProvider) {
0,
createCopyLinkToBlockMenuItem(framework)
);

clipboardGroup.items.splice(
copyIndex + 1,
0,
createCopyAsPngMenuItem(framework)
);
}

return groups;
Expand Down
Loading

0 comments on commit a17aaa5

Please sign in to comment.