Skip to content

Commit d64597e

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

File tree

15 files changed

+465
-195
lines changed

15 files changed

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

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)