Skip to content

Commit 8d3d611

Browse files
committed
feat: support copy as png
1 parent 68337fa commit 8d3d611

File tree

15 files changed

+462
-195
lines changed

15 files changed

+462
-195
lines changed

packages/common/env/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"private": true,
44
"type": "module",
55
"devDependencies": {
6-
"@blocksuite/affine": "0.18.0",
6+
"@blocksuite/affine": "0.0.0-canary-20241127081342",
77
"vitest": "2.1.6"
88
},
99
"exports": {

packages/common/infra/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"@affine/debug": "workspace:*",
1616
"@affine/env": "workspace:*",
1717
"@affine/templates": "workspace:*",
18-
"@blocksuite/affine": "0.18.0",
18+
"@blocksuite/affine": "0.0.0-canary-20241127081342",
1919
"@datastructures-js/binary-search-tree": "^5.3.2",
2020
"eventemitter2": "^6.4.9",
2121
"foxact": "^0.2.33",

packages/frontend/apps/android/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
"@affine/component": "workspace:*",
1414
"@affine/core": "workspace:*",
1515
"@affine/i18n": "workspace:*",
16-
"@blocksuite/affine": "0.18.0",
17-
"@blocksuite/icons": "^2.1.70",
16+
"@blocksuite/affine": "0.0.0-canary-20241127081342",
17+
"@blocksuite/icons": "2.1.71",
1818
"@capacitor/android": "^6.1.2",
1919
"@capacitor/core": "^6.1.2",
2020
"@sentry/react": "^8.0.0",

packages/frontend/apps/electron/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"@affine/core": "workspace:*",
2929
"@affine/i18n": "workspace:*",
3030
"@affine/native": "workspace:*",
31-
"@blocksuite/affine": "0.18.0",
31+
"@blocksuite/affine": "0.0.0-canary-20241127081342",
3232
"@electron-forge/cli": "^7.3.0",
3333
"@electron-forge/core": "^7.3.0",
3434
"@electron-forge/core-utils": "^7.3.0",

packages/frontend/apps/electron/src/main/ui/handlers.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { app, nativeTheme, shell } from 'electron';
1+
import { app, clipboard, nativeImage, nativeTheme, shell } from 'electron';
22
import { getLinkPreview } from 'link-preview-js';
33

44
import { isMacOS } from '../../shared/utils';
@@ -232,4 +232,23 @@ export const uiHandlers = {
232232
e.sender.session.setSpellCheckerLanguages([language, 'en-US']);
233233
}
234234
},
235+
captureArea: async (e, { x, y, width, height }: Electron.Rectangle) => {
236+
const image = await e.sender.capturePage({
237+
x: Math.floor(x),
238+
y: Math.floor(y),
239+
width: Math.floor(width),
240+
height: Math.floor(height),
241+
});
242+
243+
if (image.isEmpty()) {
244+
throw new Error('Image is empty or invalid');
245+
}
246+
247+
const buffer = image.toPNG();
248+
if (!buffer || !buffer.length) {
249+
throw new Error('Failed to generate PNG buffer from image');
250+
}
251+
252+
clipboard.writeImage(nativeImage.createFromBuffer(buffer));
253+
},
235254
} satisfies NamespaceHandlers;

packages/frontend/apps/ios/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
"@affine/component": "workspace:*",
1616
"@affine/core": "workspace:*",
1717
"@affine/i18n": "workspace:*",
18-
"@blocksuite/affine": "0.18.0",
19-
"@blocksuite/icons": "^2.1.70",
18+
"@blocksuite/affine": "0.0.0-canary-20241127081342",
19+
"@blocksuite/icons": "2.1.71",
2020
"@capacitor/app": "^6.0.1",
2121
"@capacitor/browser": "^6.0.3",
2222
"@capacitor/core": "^6.1.2",

packages/frontend/apps/mobile/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
"@affine/component": "workspace:*",
1414
"@affine/core": "workspace:*",
1515
"@affine/i18n": "workspace:*",
16-
"@blocksuite/affine": "0.18.0",
17-
"@blocksuite/icons": "^2.1.70",
16+
"@blocksuite/affine": "0.0.0-canary-20241127081342",
17+
"@blocksuite/icons": "2.1.71",
1818
"@sentry/react": "^8.0.0",
1919
"react": "^18.2.0",
2020
"react-dom": "^18.2.0",

packages/frontend/component/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
},
1515
"peerDependencies": {
1616
"@blocksuite/affine": "*",
17-
"@blocksuite/icons": "2.1.68"
17+
"@blocksuite/icons": "2.1.71"
1818
},
1919
"dependencies": {
2020
"@affine/cli": "workspace:*",
@@ -63,7 +63,7 @@
6363
"zod": "^3.22.4"
6464
},
6565
"devDependencies": {
66-
"@blocksuite/affine": "0.18.0",
66+
"@blocksuite/affine": "0.0.0-canary-20241127081342",
6767
"@blocksuite/icons": "2.1.71",
6868
"@chromatic-com/storybook": "^3.0.0",
6969
"@storybook/addon-essentials": "^8.2.9",

packages/frontend/core/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"@affine/i18n": "workspace:*",
1717
"@affine/templates": "workspace:*",
1818
"@affine/track": "workspace:*",
19-
"@blocksuite/affine": "0.18.0",
19+
"@blocksuite/affine": "0.0.0-canary-20241127081342",
2020
"@blocksuite/icons": "2.1.71",
2121
"@capacitor/app": "^6.0.1",
2222
"@capacitor/browser": "^6.0.3",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { notify } from '@affine/component';
2+
import {
3+
isMindmapChild,
4+
isMindMapRoot,
5+
} from '@affine/core/blocksuite/presets/ai/utils/edgeless';
6+
import { EditorService } from '@affine/core/modules/editor';
7+
import { apis } from '@affine/electron-api';
8+
import { I18n } from '@affine/i18n';
9+
import type { BlockStdScope } from '@blocksuite/affine/block-std';
10+
import {
11+
type GfxBlockElementModel,
12+
type GfxModel,
13+
GfxPrimitiveElementModel,
14+
isGfxGroupCompatibleModel,
15+
} from '@blocksuite/affine/block-std/gfx';
16+
import type {
17+
EdgelessRootService,
18+
MenuContext,
19+
} from '@blocksuite/affine/blocks';
20+
import { Bound, getCommonBound } from '@blocksuite/affine/global/utils';
21+
import { CopyAsImgaeIcon } from '@blocksuite/icons/lit';
22+
import type { FrameworkProvider } from '@toeverything/infra';
23+
24+
const snapshotStyle = `
25+
affine-edgeless-root .widgets-container,
26+
.copy-as-image-transparent {
27+
opacity: 0;
28+
}
29+
.edgeless-background {
30+
background-image: none;
31+
}
32+
`;
33+
34+
function getSelectedRect() {
35+
const selected = document
36+
.querySelector('edgeless-selected-rect')
37+
?.shadowRoot?.querySelector('.affine-edgeless-selected-rect');
38+
if (!selected) {
39+
throw new Error('Missing edgeless selected rect');
40+
}
41+
return selected.getBoundingClientRect();
42+
}
43+
44+
function expandBound(bound: Bound, margin: number) {
45+
const x = bound.x - margin;
46+
const y = bound.y - margin;
47+
const w = bound.w + margin * 2;
48+
const h = bound.h + margin * 2;
49+
return new Bound(x, y, w, h);
50+
}
51+
52+
function isOverlap(target: Bound, source: Bound) {
53+
const { x, y, w, h } = source;
54+
const left = target.x;
55+
const top = target.y;
56+
const right = target.x + target.w;
57+
const bottom = target.y + target.h;
58+
59+
return x < right && y < bottom && x + w > left && y + h > top;
60+
}
61+
62+
function isInside(target: Bound, source: Bound) {
63+
const { x, y, w, h } = source;
64+
const left = target.x;
65+
const top = target.y;
66+
const right = target.x + target.w;
67+
const bottom = target.y + target.h;
68+
69+
return x >= left && y >= top && x + w <= right && y + h <= bottom;
70+
}
71+
72+
function hideEdgelessElements(elements: GfxModel[], std: BlockStdScope) {
73+
elements.forEach(ele => {
74+
if (ele instanceof GfxPrimitiveElementModel) {
75+
(ele as any).lastOpacity = ele.opacity;
76+
ele.opacity = 0;
77+
} else {
78+
const block = std.view.getBlock(ele.id);
79+
if (!block) return;
80+
block.classList.add('copy-as-image-transparent');
81+
}
82+
});
83+
}
84+
85+
function showEdgelessElements(elements: GfxModel[], std: BlockStdScope) {
86+
elements.forEach(ele => {
87+
if (ele instanceof GfxPrimitiveElementModel) {
88+
ele.opacity = (ele as any).lastOpacity;
89+
delete (ele as any).lastOpacity;
90+
} else {
91+
const block = std.view.getBlock(ele.id);
92+
if (!block) return;
93+
block.classList.remove('copy-as-image-transparent');
94+
}
95+
});
96+
}
97+
98+
function withDescendantElements(elements: GfxModel[]) {
99+
const set = new Set<GfxModel>();
100+
elements.forEach(element => {
101+
if (set.has(element)) return;
102+
set.add(element);
103+
if (isGfxGroupCompatibleModel(element)) {
104+
element.descendantElements.map(descendant => set.add(descendant));
105+
}
106+
});
107+
return [...set];
108+
}
109+
110+
const MARGIN = 20;
111+
112+
export function createCopyAsPngMenuItem(framework: FrameworkProvider) {
113+
return {
114+
icon: CopyAsImgaeIcon({ width: '20', height: '20' }),
115+
label: 'Copy as Image',
116+
type: 'copy-as-image',
117+
when: (ctx: MenuContext) => {
118+
if (ctx.isEmpty()) return false;
119+
const { editor } = framework.get(EditorService);
120+
const mode = editor.mode$.value;
121+
return mode === 'edgeless';
122+
},
123+
action: async (ctx: MenuContext) => {
124+
if (!apis) {
125+
notify.error({
126+
title: I18n.t('com.affine.copy.asImage.notAvailable.title'),
127+
message: I18n.t('com.affine.copy.asImage.notAvailable.message'),
128+
action: {
129+
label: I18n.t('com.affine.copy.asImage.notAvailable.action'),
130+
onClick: () => {
131+
window.open('https://affine.pro/download');
132+
},
133+
},
134+
});
135+
return;
136+
}
137+
138+
const service =
139+
ctx.host.std.getService<EdgelessRootService>('affine:page');
140+
if (!service) return;
141+
142+
let selected = service.selection.selectedElements;
143+
// select mindmap if root node selected
144+
const maybeMindmap = selected[0];
145+
const mindmapId = maybeMindmap.group?.id;
146+
if (
147+
selected.length === 1 &&
148+
mindmapId &&
149+
(isMindMapRoot(maybeMindmap) || isMindmapChild(maybeMindmap))
150+
) {
151+
service.gfx.selection.set({ elements: [mindmapId] });
152+
}
153+
154+
// select bound
155+
selected = service.selection.selectedElements;
156+
const elements = withDescendantElements(selected);
157+
const bounds = elements.map(element => Bound.deserialize(element.xywh));
158+
const bound = getCommonBound(bounds);
159+
if (!bound) return;
160+
const { zoom } = service.viewport;
161+
const exBound = expandBound(bound, MARGIN * zoom);
162+
163+
// fit to screen
164+
if (
165+
!isInside(service.viewport.viewportBounds, exBound) ||
166+
service.viewport.zoom < 1
167+
) {
168+
service.viewport.setViewportByBound(bound, [20, 20, 20, 20], false);
169+
if (service.viewport.zoom > 1) {
170+
service.viewport.setZoom(1);
171+
}
172+
}
173+
174+
// hide unselected overlap elements
175+
const overlapElements = service.gfx.gfxElements.filter(ele => {
176+
const eleBound = Bound.deserialize(ele.xywh);
177+
const exEleBound = expandBound(eleBound, MARGIN * zoom);
178+
const isSelected = elements.includes(ele);
179+
return !isSelected && isOverlap(exBound, exEleBound);
180+
});
181+
hideEdgelessElements(overlapElements, ctx.host.std);
182+
183+
// add css style
184+
const styleEle = document.createElement('style');
185+
styleEle.innerHTML = snapshotStyle;
186+
document.head.append(styleEle);
187+
188+
// capture image
189+
setTimeout(() => {
190+
if (!apis) return;
191+
try {
192+
const domRect = getSelectedRect();
193+
const { zoom } = service.viewport;
194+
const isFrameSelected =
195+
selected.length === 1 &&
196+
(selected[0] as GfxBlockElementModel).flavour === 'affine:frame';
197+
const margin = isFrameSelected ? -2 : MARGIN * zoom;
198+
199+
service.selection.clear();
200+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
201+
apis.ui
202+
.captureArea({
203+
x: domRect.left - margin,
204+
y: domRect.top - margin,
205+
width: domRect.width + margin * 2,
206+
height: domRect.height + margin * 2,
207+
})
208+
.then(() => {
209+
notify.success({
210+
title: I18n.t('com.affine.copy.asImage.success'),
211+
});
212+
})
213+
.catch(e => {
214+
notify.error({
215+
title: I18n.t('com.affine.copy.asImage.failed'),
216+
message: String(e),
217+
});
218+
})
219+
.finally(() => {
220+
styleEle.remove();
221+
showEdgelessElements(overlapElements, ctx.host.std);
222+
});
223+
} catch (e) {
224+
styleEle.remove();
225+
showEdgelessElements(overlapElements, ctx.host.std);
226+
notify.error({
227+
title: I18n.t('com.affine.copy.asImage.failed'),
228+
message: String(e),
229+
});
230+
}
231+
}, 100);
232+
},
233+
};
234+
}

packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/toolbar.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import type {
1212
GfxBlockElementModel,
1313
GfxPrimitiveElementModel,
1414
} from '@blocksuite/affine/block-std/gfx';
15-
import type { MenuContext } from '@blocksuite/affine/blocks';
15+
import { type MenuContext } from '@blocksuite/affine/blocks';
1616
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
1717
import { LinkIcon } from '@blocksuite/icons/lit';
1818
import type { FrameworkProvider } from '@toeverything/infra';
1919

20+
import { createCopyAsPngMenuItem } from './copy-as-image';
21+
2022
export function createToolbarMoreMenuConfig(framework: FrameworkProvider) {
2123
return {
2224
configure: <T extends MenuContext>(groups: MenuItemGroup<T>[]) => {
@@ -41,6 +43,12 @@ export function createToolbarMoreMenuConfig(framework: FrameworkProvider) {
4143
0,
4244
createCopyLinkToBlockMenuItem(framework)
4345
);
46+
47+
clipboardGroup.items.splice(
48+
copyIndex + 1,
49+
0,
50+
createCopyAsPngMenuItem(framework)
51+
);
4452
}
4553

4654
return groups;

0 commit comments

Comments
 (0)