snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
{
"name": "@blocksuite/affine-block-image",
"description": "Image block for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/affine-widget-slash-menu": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"file-type": "^21.0.0",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./turbo-painter": "./src/turbo/image-painter.worker.ts",
"./store": "./src/store.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,11 @@
import type { ExtensionType } from '@blocksuite/store';
import { ImageBlockHtmlAdapterExtension } from './html.js';
import { ImageBlockMarkdownAdapterExtension } from './markdown.js';
import { ImageBlockNotionHtmlAdapterExtension } from './notion-html.js';
export const ImageBlockAdapterExtensions: ExtensionType[] = [
ImageBlockHtmlAdapterExtension,
ImageBlockMarkdownAdapterExtension,
ImageBlockNotionHtmlAdapterExtension,
];

View File

@@ -0,0 +1,90 @@
import { ImageBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import { getAssetName } from '@blocksuite/store';
import { processImageNodeToBlock } from './utils';
export const imageBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: ImageBlockSchema.model.flavour,
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'img',
fromMatch: o => o.node.flavour === ImageBlockSchema.model.flavour,
toBlockSnapshot: {
enter: async (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { assets, walkerContext, configs } = context;
if (!assets) {
return;
}
const image = o.node;
const imageURL =
typeof image?.properties.src === 'string' ? image.properties.src : '';
if (!imageURL) {
return;
}
await processImageNodeToBlock(imageURL, walkerContext, assets, configs);
},
},
fromBlockSnapshot: {
enter: async (o, context) => {
const blobId = (o.node.props.sourceId ?? '') as string;
const { assets, walkerContext, updateAssetIds } = context;
if (!assets) {
return;
}
await assets.readFromBlob(blobId);
const blob = assets.getAssets().get(blobId);
updateAssetIds?.(blobId);
if (!blob) {
return;
}
const blobName = getAssetName(assets.getAssets(), blobId);
const isScaledImage = o.node.props.width && o.node.props.height;
const widthStyle = isScaledImage
? {
width: `${o.node.props.width}px`,
height: `${o.node.props.height}px`,
}
: {};
walkerContext
.openNode(
{
type: 'element',
tagName: 'figure',
properties: {
className: ['affine-image-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'img',
properties: {
src: `assets/${blobName}`,
alt: blobName,
title: (o.node.props.caption as string | undefined) ?? null,
...widthStyle,
},
children: [],
},
'children'
)
.closeNode()
.closeNode();
},
},
};
export const ImageBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
imageBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './notion-html.js';

View File

@@ -0,0 +1,66 @@
import { ImageBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { getAssetName } from '@blocksuite/store';
import { processImageNodeToBlock } from './utils';
const isImageNode = (node: MarkdownAST) => node.type === 'image';
export const imageBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
flavour: ImageBlockSchema.model.flavour,
toMatch: o => isImageNode(o.node),
fromMatch: o => o.node.flavour === ImageBlockSchema.model.flavour,
toBlockSnapshot: {
enter: async (o, context) => {
const { configs, walkerContext, assets } = context;
const imageURL = 'url' in o.node ? o.node.url : '';
if (!assets || !imageURL) {
return;
}
await processImageNodeToBlock(imageURL, walkerContext, assets, configs);
},
},
fromBlockSnapshot: {
enter: async (o, context) => {
const { assets, walkerContext, updateAssetIds } = context;
const blobId = (o.node.props.sourceId ?? '') as string;
if (!assets) {
return;
}
await assets.readFromBlob(blobId);
const blob = assets.getAssets().get(blobId);
if (!blob) {
return;
}
const blobName = getAssetName(assets.getAssets(), blobId);
updateAssetIds?.(blobId);
walkerContext
.openNode(
{
type: 'paragraph',
children: [],
},
'children'
)
.openNode(
{
type: 'image',
url: `assets/${blobName}`,
title: (o.node.props.caption as string | undefined) ?? null,
alt: (blob as File).name ?? null,
},
'children'
)
.closeNode()
.closeNode();
},
},
};
export const ImageBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
imageBlockMarkdownAdapterMatcher
);

View File

@@ -0,0 +1,82 @@
import { ImageBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import { processImageNodeToBlock } from './utils';
export const imageBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: ImageBlockSchema.model.flavour,
toMatch: o => {
return (
HastUtils.isElement(o.node) &&
(o.node.tagName === 'img' ||
(o.node.tagName === 'figure' &&
!!HastUtils.querySelector(o.node, '.image')))
);
},
fromMatch: () => false,
toBlockSnapshot: {
enter: async (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { assets, walkerContext, configs } = context;
if (!assets) {
return;
}
if (walkerContext.getGlobalContext('hast:disableimg')) {
return;
}
switch (o.node.tagName) {
case 'img': {
const image = o.node;
const imageURL =
typeof image?.properties.src === 'string'
? image.properties.src
: '';
if (imageURL) {
await processImageNodeToBlock(
imageURL,
walkerContext,
assets,
configs
);
}
break;
}
case 'figure': {
const imageFigureWrapper = HastUtils.querySelector(
o.node,
'.image'
);
let imageURL = '';
if (imageFigureWrapper) {
const image = HastUtils.querySelector(imageFigureWrapper, 'img');
imageURL =
typeof image?.properties.src === 'string'
? image.properties.src
: '';
}
if (imageURL) {
await processImageNodeToBlock(
imageURL,
walkerContext,
assets,
configs
);
}
break;
}
}
},
},
fromBlockSnapshot: {},
};
export const ImageBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(imageBlockNotionHtmlAdapterMatcher);

View File

@@ -0,0 +1,89 @@
import { ImageBlockSchema } from '@blocksuite/affine-model';
import {
FetchUtils,
FULL_FILE_PATH_KEY,
getImageFullPath,
} from '@blocksuite/affine-shared/adapters';
import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils';
import { sha } from '@blocksuite/global/utils';
import {
type AssetsManager,
type ASTWalkerContext,
type BlockSnapshot,
nanoid,
} from '@blocksuite/store';
export async function processImageNodeToBlock(
imageURL: string,
walkerContext: ASTWalkerContext<BlockSnapshot>,
assets: AssetsManager,
configs: Map<string, string>
) {
let blobId = '';
if (!FetchUtils.fetchable(imageURL)) {
const fullFilePath = configs.get(FULL_FILE_PATH_KEY);
// When importing markdown file with assets in a zip file,
// the image URL is the relative path of the image file in the zip file
// If the full file path is provided, use it to get the image full path
if (fullFilePath) {
const decodedImageURL = decodeURIComponent(imageURL);
const imageFullPath = getImageFullPath(fullFilePath, decodedImageURL);
blobId = assets.getPathBlobIdMap().get(imageFullPath) ?? '';
} else {
const imageURLSplit = imageURL.split('/');
while (imageURLSplit.length > 0) {
const key = assets
.getPathBlobIdMap()
.get(decodeURIComponent(imageURLSplit.join('/')));
if (key) {
blobId = key;
break;
}
imageURLSplit.shift();
}
}
} else {
try {
const res = await FetchUtils.fetchImage(
imageURL,
undefined,
configs.get('imageProxy') as string
);
if (!res) {
return;
}
const clonedRes = res.clone();
const name =
getFilenameFromContentDisposition(
res.headers.get('Content-Disposition') ?? ''
) ??
(imageURL.split('/').at(-1) ?? 'image') +
'.' +
(res.headers.get('Content-Type')?.split('/').at(-1) ?? 'png');
const file = new File([await res.blob()], name, {
type: res.headers.get('Content-Type') ?? '',
});
blobId = await sha(await clonedRes.arrayBuffer());
assets?.getAssets().set(blobId, file);
await assets?.writeToBlob(blobId);
} catch (err) {
console.error('Failed to process image:', err);
return;
}
}
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: ImageBlockSchema.model.flavour,
props: {
sourceId: blobId,
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
}

View File

@@ -0,0 +1 @@
export { insertImagesCommand } from './insert-images.js';

View File

@@ -0,0 +1,40 @@
import { getImageFilesFromLocal } from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { addSiblingImageBlocks } from '../utils';
export const insertImagesCommand: Command<
{
selectedModels?: BlockModel[];
removeEmptyLine?: boolean;
placement?: 'after' | 'before';
},
{
insertedImageIds: Promise<string[]>;
}
> = (ctx, next) => {
const { selectedModels, placement, removeEmptyLine, std } = ctx;
if (!selectedModels?.length) return;
const targetModel =
placement === 'before'
? selectedModels[0]
: selectedModels[selectedModels.length - 1];
return next({
insertedImageIds: getImageFilesFromLocal()
.then(files => addSiblingImageBlocks(std, files, targetModel, placement))
.then(result => {
if (
result.length &&
removeEmptyLine &&
targetModel.text?.length === 0
) {
std.store.deleteBlock(targetModel);
}
return result;
}),
});
};

View File

@@ -0,0 +1,126 @@
import type { ResolvedStateInfo } from '@blocksuite/affine-components/resource';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
export const SURFACE_IMAGE_CARD_WIDTH = 220;
export const SURFACE_IMAGE_CARD_HEIGHT = 122;
export const NOTE_IMAGE_CARD_WIDTH = 752;
export const NOTE_IMAGE_CARD_HEIGHT = 78;
export class ImageBlockFallbackCard extends WithDisposable(ShadowlessElement) {
static override styles = css`
affine-image-fallback-card {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.affine-image-fallback-card {
display: flex;
flex: 1;
gap: 8px;
align-self: stretch;
flex-direction: column;
justify-content: space-between;
border-radius: 8px;
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
background: ${unsafeCSSVarV2('layer/background/secondary')};
padding: 12px;
}
.truncate {
align-self: stretch;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.affine-image-fallback-card-title {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
}
.affine-image-fallback-card-title-icon {
display: flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
color: var(--affine-text-primary-color);
}
.affine-image-fallback-card-title-text {
color: var(--affine-placeholder-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px;
}
.affine-image-fallback-card-description {
color: var(--affine-text-secondary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-image-fallback-card.loading {
.affine-image-fallback-card-title {
color: var(--affine-placeholder-color);
}
}
.affine-image-fallback-card.error {
.affine-image-fallback-card-title-icon {
color: ${unsafeCSSVarV2('status/error')};
}
}
`;
override render() {
const { icon, title, description, loading, error } = this.state;
const classInfo = {
'affine-image-fallback-card': true,
'drag-target': true,
loading,
error,
};
return html`
<div class=${classMap(classInfo)}>
<div class="affine-image-fallback-card-title">
<div class="affine-image-fallback-card-title-icon">${icon}</div>
<div class="affine-image-fallback-card-title-text truncate">
${title}
</div>
</div>
<div class="affine-image-fallback-card-description truncate">
${description}
</div>
</div>
`;
}
@property({ attribute: false })
accessor state!: ResolvedStateInfo;
}
declare global {
interface HTMLElementTagNameMap {
'affine-image-fallback-card': ImageBlockFallbackCard;
}
}

View File

@@ -0,0 +1,83 @@
import { html } from 'lit';
import { when } from 'lit/directives/when.js';
const styles = html`<style>
.affine-page-selected-embed-rects-container {
position: absolute;
border: 2px solid var(--affine-primary-color);
left: 0;
top: 0;
width: 100%;
height: calc(100% + 1px);
user-select: none;
pointer-events: none;
box-sizing: border-box;
line-height: 0;
}
.affine-page-selected-embed-rects-container .resize {
position: absolute;
padding: 5px;
pointer-events: auto;
z-index: 1;
}
.affine-page-selected-embed-rects-container .resize-inner {
width: 10px;
height: 10px;
border-radius: 50%;
background: white;
border: 2px solid var(--affine-primary-color);
pointer-events: none;
}
.affine-page-selected-embed-rects-container .resize.top-left {
left: 0;
top: 0;
transform: translate(-50%, -50%);
cursor: nwse-resize; /*resizer cursor*/
}
.affine-page-selected-embed-rects-container .resize.top-right {
right: 0;
top: 0;
transform: translate(50%, -50%);
cursor: nesw-resize;
}
.affine-page-selected-embed-rects-container .resize.bottom-left {
left: 0;
bottom: 0;
transform: translate(-50%, 50%);
cursor: nesw-resize;
}
.affine-page-selected-embed-rects-container .resize.bottom-right {
right: 0;
bottom: 0;
transform: translate(50%, 50%);
cursor: nwse-resize;
}
</style>`;
export function ImageSelectedRect(readonly: boolean) {
return html`
${styles}
<div class="affine-page-selected-embed-rects-container resizable resizes">
${when(
!readonly,
() => html`
<div class="resize top-left">
<div class="resize-inner"></div>
</div>
<div class="resize top-right">
<div class="resize-inner"></div>
</div>
<div class="resize bottom-left">
<div class="resize-inner"></div>
</div>
<div class="resize bottom-right">
<div class="resize-inner"></div>
</div>
`
)}
</div>
`;
}

View File

@@ -0,0 +1,422 @@
import type { ResolvedStateInfo } from '@blocksuite/affine-components/resource';
import {
focusBlockEnd,
focusBlockStart,
getNextBlockCommand,
getPrevBlockCommand,
} from '@blocksuite/affine-shared/commands';
import { ImageSelection } from '@blocksuite/affine-shared/selection';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import type { BlockComponent, UIEventStateContext } from '@blocksuite/std';
import {
BlockSelection,
ShadowlessElement,
TextSelection,
} from '@blocksuite/std';
import type { BaseSelection } from '@blocksuite/store';
import { computed } from '@preact/signals-core';
import { css, html, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import type { ImageBlockComponent } from '../image-block';
import { ImageResizeManager } from '../image-resize-manager';
import { shouldResizeImage } from '../utils';
import { ImageSelectedRect } from './image-selected-rect';
export class ImageBlockPageComponent extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
affine-page-image {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 0;
cursor: pointer;
}
affine-page-image .loading {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 4px;
left: 4px;
width: 36px;
height: 36px;
padding: 5px;
border-radius: 8px;
background: ${unsafeCSSVarV2(
'loading/imageLoadingBackground',
'#92929238'
)};
& > svg {
font-size: 25.71px;
}
}
affine-page-image .affine-image-status {
position: absolute;
left: 18px;
bottom: 18px;
}
affine-page-image .resizable-img {
position: relative;
max-width: 100%;
}
affine-page-image .resizable-img img {
width: 100%;
height: 100%;
}
affine-page-image .comment-highlighted {
outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')};
}
`;
resizeable$ = computed(() => this.block.resizeable$.value);
private _isDragging = false;
private get _doc() {
return this.block.store;
}
private get _host() {
return this.block.host;
}
private get _model() {
return this.block.model;
}
private _bindKeyMap() {
const selection = this._host.selection;
const addParagraph = (ctx: UIEventStateContext) => {
const parent = this._doc.getParent(this._model);
if (!parent) return;
const index = parent.children.indexOf(this._model);
const blockId = this._doc.addBlock(
'affine:paragraph',
{},
parent,
index + 1
);
const event = ctx.get('defaultState').event;
event.preventDefault();
selection.update(selList =>
selList
.filter<BaseSelection>(sel => !sel.is(ImageSelection))
.concat(
selection.create(TextSelection, {
from: {
blockId,
index: 0,
length: 0,
},
to: null,
})
)
);
};
// TODO: use key map extension
this.block.bindHotKey({
Escape: () => {
selection.update(selList => {
return selList.map(sel => {
const current =
sel.is(ImageSelection) && sel.blockId === this.block.blockId;
if (current) {
return selection.create(BlockSelection, {
blockId: this.block.blockId,
});
}
return sel;
});
});
return true;
},
Delete: ctx => {
if (this._host.store.readonly || !this.resizeable$.peek()) return;
addParagraph(ctx);
this._doc.deleteBlock(this._model);
return true;
},
Backspace: ctx => {
if (this._host.store.readonly || !this.resizeable$.peek()) return;
addParagraph(ctx);
this._doc.deleteBlock(this._model);
return true;
},
Enter: ctx => {
if (this._host.store.readonly || !this.resizeable$.peek()) return;
addParagraph(ctx);
return true;
},
ArrowDown: ctx => {
const std = this._host.std;
// If the selection is not image selection, we should not handle it.
if (!std.selection.find(ImageSelection)) {
return false;
}
const event = ctx.get('keyboardState');
event.raw.preventDefault();
std.command
.chain()
.pipe(getNextBlockCommand, { path: this.block.blockId })
.pipe<{ focusBlock: BlockComponent }>((ctx, next) => {
const { nextBlock } = ctx;
if (!nextBlock) return;
return next({ focusBlock: nextBlock });
})
.pipe(focusBlockStart)
.run();
return true;
},
ArrowUp: ctx => {
const std = this._host.std;
// If the selection is not image selection, we should not handle it.
if (!std.selection.find(ImageSelection)) {
return false;
}
const event = ctx.get('keyboardState');
event.raw.preventDefault();
std.command
.chain()
.pipe(getPrevBlockCommand, { path: this.block.blockId })
.pipe<{ focusBlock: BlockComponent }>((ctx, next) => {
const { prevBlock } = ctx;
if (!prevBlock) return;
return next({ focusBlock: prevBlock });
})
.pipe(focusBlockEnd)
.run();
return true;
},
});
}
private _handleError() {
this.block.resourceController.updateState({
errorMessage: 'Failed to download image!',
});
}
private _handleSelection() {
const selection = this._host.selection;
this._disposables.addFromEvent(
this.resizeImg,
'click',
(event: MouseEvent) => {
// the peek view need handle shift + click
if (event.shiftKey) return;
event.stopPropagation();
selection.update(selList => {
return selList
.filter(sel => !['block', 'image', 'text'].includes(sel.type))
.concat(
selection.create(ImageSelection, { blockId: this.block.blockId })
);
});
return true;
}
);
this.block.handleEvent(
'click',
() => {
if (!this.resizeable$.peek()) return;
selection.update(selList =>
selList.filter(
sel =>
!(sel.is(ImageSelection) && sel.blockId === this.block.blockId)
)
);
},
{
global: true,
}
);
}
private _normalizeImageSize() {
// If is dragging, we should use the real size of the image
if (this._isDragging && this.resizeImg) {
return {
width: this.resizeImg.style.width,
};
}
const { width, height } = this._model.props;
if (!width || !height) {
return {
width: 'unset',
height: 'unset',
};
}
return {
width: `${width}px`,
};
}
private _observeDrag() {
const imageResizeManager = new ImageResizeManager();
this._disposables.add(
this._host.event.add('dragStart', ctx => {
const pointerState = ctx.get('pointerState');
const target = pointerState.event.target;
if (shouldResizeImage(this, target)) {
this._isDragging = true;
imageResizeManager.onStart(pointerState);
return true;
}
return false;
})
);
this._disposables.add(
this._host.event.add('dragMove', ctx => {
const pointerState = ctx.get('pointerState');
if (this._isDragging) {
imageResizeManager.onMove(pointerState);
return true;
}
return false;
})
);
this._disposables.add(
this._host.event.add('dragEnd', () => {
if (this._isDragging) {
this._isDragging = false;
imageResizeManager.onEnd();
return true;
}
return false;
})
);
}
override connectedCallback() {
super.connectedCallback();
this._bindKeyMap();
this._observeDrag();
}
override firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._handleSelection();
// The embed block can not be focused,
// so the active element will be the last activated element.
// If the active element is the title textarea,
// any event will dispatch from it and be ignored. (Most events will ignore title)
// so we need to blur it.
// See also https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement
this.addEventListener('click', () => {
if (
document.activeElement &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
});
}
override render() {
const imageSize = this._normalizeImageSize();
const imageSelectedRect = this.resizeable$.value
? ImageSelectedRect(this._doc.readonly)
: null;
const blobUrl = this.block.blobUrl;
const caption = this.block.model.props.caption$.value ?? 'Image';
const { loading, error, icon, description, needUpload } = this.state;
return html`
<div
class=${classMap({
'resizable-img': true,
'comment-highlighted': this.block.isCommentHighlighted,
})}
style=${styleMap(imageSize)}
>
<img
class="drag-target"
draggable="false"
loading="lazy"
src=${blobUrl}
alt=${caption}
@error=${this._handleError}
/>
${imageSelectedRect}
</div>
${when(loading, () => html`<div class="loading">${icon}</div>`)}
${when(
Boolean(error && description),
() =>
html`<affine-resource-status
class="affine-image-status"
.message=${description}
.needUpload=${needUpload}
.action=${() =>
needUpload
? this.block.resourceController.upload()
: this.block.refreshData()}
></affine-resource-status>`
)}
`;
}
@property({ attribute: false })
accessor block!: ImageBlockComponent;
@property({ attribute: false })
accessor state!: ResolvedStateInfo;
@query('.resizable-img')
accessor resizeImg!: HTMLElement;
}
declare global {
interface HTMLElementTagNameMap {
'affine-page-image': ImageBlockPageComponent;
}
}

View File

@@ -0,0 +1,32 @@
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
import { ImageIcon } from '@blocksuite/icons/lit';
import { insertImagesCommand } from '../commands';
import { PhotoTooltip } from './tooltips';
export const imageSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'Image',
description: 'Insert an image.',
icon: ImageIcon(),
tooltip: {
figure: PhotoTooltip,
caption: 'Photo',
},
group: '4_Content & Media@1',
when: ({ model }) =>
model.store.schema.flavourSchemaMap.has('affine:image'),
action: ({ std }) => {
const [success, ctx] = std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertImagesCommand, { removeEmptyLine: true })
.run();
if (success) ctx.insertedImageIds.catch(console.error);
},
},
],
};

View File

@@ -0,0 +1,222 @@
import { updateBlockAlign } from '@blocksuite/affine-block-note';
import { ImageBlockModel, TextAlign } from '@blocksuite/affine-model';
import {
ActionPlacement,
blockCommentToolbarButton,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import {
BookmarkIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
DuplicateIcon,
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { ImageBlockComponent } from '../image-block';
import { ImageEdgelessBlockComponent } from '../image-edgeless-block';
import { duplicate } from '../utils';
const trackBaseProps = {
category: 'image',
type: 'card view',
};
const builtinToolbarConfig = {
actions: [
{
id: 'a.download',
tooltip: 'Download',
icon: DownloadIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
block?.download();
},
},
{
id: 'b.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
block?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
id: 'c.1.align-left',
tooltip: 'Align left',
icon: TextAlignLeftIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
if (block) {
ctx.chain
.pipe(updateBlockAlign, {
textAlign: TextAlign.Left,
selectedBlocks: [block],
})
.run();
}
},
},
{
id: 'c.2.align-center',
tooltip: 'Align center',
icon: TextAlignCenterIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
if (block) {
ctx.chain
.pipe(updateBlockAlign, {
textAlign: TextAlign.Center,
selectedBlocks: [block],
})
.run();
}
},
},
{
id: 'c.3.align-right',
tooltip: 'Align right',
icon: TextAlignRightIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
if (block) {
ctx.chain
.pipe(updateBlockAlign, {
textAlign: TextAlign.Right,
selectedBlocks: [block],
})
.run();
}
},
},
{
id: 'd.comment',
...blockCommentToolbarButton,
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'a.copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
block?.copy();
},
},
{
id: 'b.duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
if (!block) return;
duplicate(block);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'b.conversions',
actions: [
{
id: 'a.turn-into-card-view',
label: 'Turn into card view',
icon: BookmarkIcon(),
when(ctx) {
const supported =
ctx.store.schema.flavourSchemaMap.has('affine:attachment');
if (!supported) return false;
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
return Boolean(block?.blobUrl);
},
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
block?.convertToCardView();
},
},
],
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
if (!block) return;
ctx.store.deleteBlock(block.model);
},
},
],
placement: 'inner',
} as const satisfies ToolbarModuleConfig;
const builtinSurfaceToolbarConfig = {
actions: [
{
id: 'a.download',
tooltip: 'Download',
icon: DownloadIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageEdgelessBlockComponent);
block?.download();
},
},
{
id: 'b.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageEdgelessBlockComponent);
block?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
],
when: ctx => ctx.getSurfaceModelsByType(ImageBlockModel).length === 1,
} as const satisfies ToolbarModuleConfig;
export const createBuiltinToolbarConfigExtension = (
flavour: string
): ExtensionType[] => {
const name = flavour.split(':').pop();
return [
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier(`affine:surface:${name}`),
config: builtinSurfaceToolbarConfig,
}),
];
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
import { type BlockSnapshot } from '@blocksuite/store';
export class EdgelessClipboardImageConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:image';
override async createBlock(image: BlockSnapshot) {
const { xywh, rotate, sourceId, size, width, height, caption } =
image.props;
if (!this.surface) return null;
if (!(await this.std.workspace.blobSync.get(sourceId as string))) {
return null;
}
return this.crud.addBlock(
'affine:image',
{
caption,
sourceId,
xywh,
rotate,
size,
width,
height,
},
this.surface.model.id
);
}
}

View File

@@ -0,0 +1,21 @@
import { ImageBlockFallbackCard } from './components/image-block-fallback.js';
import { ImageBlockPageComponent } from './components/page-image-block.js';
import { ImageBlockComponent } from './image-block.js';
import { ImageEdgelessBlockComponent } from './image-edgeless-block.js';
import { ImageEdgelessPlaceholderBlockComponent } from './preview-image/edgeless.js';
import { ImagePlaceholderBlockComponent } from './preview-image/page.js';
export function effects() {
customElements.define('affine-image', ImageBlockComponent);
customElements.define('affine-edgeless-image', ImageEdgelessBlockComponent);
customElements.define('affine-page-image', ImageBlockPageComponent);
customElements.define('affine-image-fallback-card', ImageBlockFallbackCard);
customElements.define(
'affine-placeholder-preview-image',
ImagePlaceholderBlockComponent
);
customElements.define(
'affine-edgeless-placeholder-preview-image',
ImageEdgelessPlaceholderBlockComponent
);
}

View File

@@ -0,0 +1,204 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { whenHover } from '@blocksuite/affine-components/hover';
import { LoadingIcon } from '@blocksuite/affine-components/icons';
import { Peekable } from '@blocksuite/affine-components/peek';
import { ResourceController } from '@blocksuite/affine-components/resource';
import type { ImageBlockModel } from '@blocksuite/affine-model';
import { ImageSelection } from '@blocksuite/affine-shared/selection';
import {
BlockElementCommentManager,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import { formatSize } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit';
import { BlockSelection } from '@blocksuite/std';
import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit';
import { query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import type { ImageBlockPageComponent } from './components/page-image-block';
import {
copyImageBlob,
downloadImageBlob,
refreshData,
turnImageIntoCardView,
} from './utils';
@Peekable({
enableOn: () => !IS_MOBILE,
})
export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel> {
resizeable$ = computed(() =>
this.std.selection.value.some(
selection =>
selection.is(ImageSelection) && selection.blockId === this.blockId
)
);
resourceController = new ResourceController(
computed(() => this.model.props.sourceId$.value),
'Image'
);
get blobUrl() {
return this.resourceController.blobUrl$.value;
}
convertToCardView = () => {
turnImageIntoCardView(this).catch(console.error);
};
copy = () => {
copyImageBlob(this).catch(console.error);
};
download = () => {
downloadImageBlob(this).catch(console.error);
};
refreshData = () => {
refreshData(this).catch(console.error);
};
get resizableImg() {
return this.pageImage?.resizeImg;
}
get isCommentHighlighted() {
return (
this.std
.getOptional(BlockElementCommentManager)
?.isBlockCommentHighlighted(this.model) ?? false
);
}
private _handleClick(event: MouseEvent) {
// the peek view need handle shift + click
if (event.defaultPrevented) return;
event.stopPropagation();
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
private _initHover() {
const { setReference, setFloating, dispose } = whenHover(
hovered => {
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: this.model.flavour,
element: this,
setFloating,
};
return;
}
// Clears previous bindings
message$.value = null;
setFloating();
},
{ enterDelay: 500 }
);
setReference(this.hoverableContainer);
this._disposables.add(dispose);
}
override connectedCallback() {
super.connectedCallback();
this.contentEditable = 'false';
this.resourceController.setEngine(this.std.store.blobSync);
this.disposables.add(this.resourceController.subscribe());
this.disposables.add(this.resourceController);
this.disposables.add(
this.model.props.sourceId$.subscribe(() => {
this.refreshData();
})
);
}
override firstUpdated() {
// lazy bindings
this.disposables.addFromEvent(this, 'click', this._handleClick);
this._initHover();
}
override renderBlock() {
const blobUrl = this.blobUrl;
const { size = 0 } = this.model.props;
const containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
const alignItemsStyleMap = styleMap({
alignItems:
this.model.props.textAlign$.value === 'left'
? 'flex-start'
: this.model.props.textAlign$.value === 'right'
? 'flex-end'
: undefined,
});
const resovledState = this.resourceController.resolveStateWith({
loadingIcon: LoadingIcon({
strokeColor: cssVarV2('button/pureWhiteText'),
ringColor: cssVarV2('loading/imageLoadingLayer', '#ffffff8f'),
}),
errorIcon: BrokenImageIcon(),
icon: ImageIcon(),
title: 'Image',
description: formatSize(size),
});
return html`
<div class="affine-image-container" style=${containerStyleMap}>
${when(
blobUrl,
() =>
html`<affine-page-image
.block=${this}
.state=${resovledState}
style="${alignItemsStyleMap}"
></affine-page-image>`,
() =>
html`<affine-image-fallback-card
.state=${resovledState}
></affine-image-fallback-card>`
)}
</div>
${Object.values(this.widgets)}
`;
}
override accessor blockContainerStyles = { margin: '18px 0' };
@query('affine-page-image')
private accessor pageImage: ImageBlockPageComponent | null = null;
@query('.affine-image-container')
accessor hoverableContainer!: HTMLDivElement;
override accessor useCaptionEditor = true;
override accessor useZeroWidth = true;
}
declare global {
interface HTMLElementTagNameMap {
'affine-image': ImageBlockComponent;
}
}

View File

@@ -0,0 +1,375 @@
import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption';
import { LoadingIcon } from '@blocksuite/affine-components/icons';
import { Peekable } from '@blocksuite/affine-components/peek';
import { ResourceController } from '@blocksuite/affine-components/resource';
import {
type ImageBlockModel,
ImageBlockSchema,
} from '@blocksuite/affine-model';
import { cssVarV2, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { formatSize } from '@blocksuite/affine-shared/utils';
import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit';
import { GfxBlockComponent } from '@blocksuite/std';
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
import { computed } from '@preact/signals-core';
import { css, html } from 'lit';
import { query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import {
copyImageBlob,
downloadImageBlob,
refreshData,
turnImageIntoCardView,
} from './utils';
@Peekable()
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
private static readonly LOD_MIN_IMAGE_BYTES = 1024 * 1024;
private static readonly LOD_MIN_IMAGE_PIXELS = 1920 * 1080;
private static readonly LOD_MAX_ZOOM = 0.4;
private static readonly LOD_THUMBNAIL_MAX_EDGE = 256;
static override styles = css`
affine-edgeless-image {
position: relative;
}
affine-edgeless-image .loading {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 4px;
right: 4px;
width: 36px;
height: 36px;
padding: 5px;
border-radius: 8px;
background: ${unsafeCSSVarV2(
'loading/imageLoadingBackground',
'#92929238'
)};
& > svg {
font-size: 25.71px;
}
}
affine-edgeless-image .affine-image-status {
position: absolute;
left: 18px;
bottom: 18px;
}
affine-edgeless-image .resizable-img,
affine-edgeless-image .resizable-img img {
width: 100%;
height: 100%;
}
affine-edgeless-image .resizable-img {
position: relative;
overflow: hidden;
}
`;
resourceController = new ResourceController(
computed(() => this.model.props.sourceId$.value),
'Image'
);
private _lodThumbnailUrl: string | null = null;
private _lodSourceUrl: string | null = null;
private _lodGeneratingSourceUrl: string | null = null;
private _lodGenerationToken = 0;
private _lastShouldUseLod = false;
get blobUrl() {
return this.resourceController.blobUrl$.value;
}
convertToCardView = () => {
turnImageIntoCardView(this).catch(console.error);
};
copy = () => {
copyImageBlob(this).catch(console.error);
};
download = () => {
downloadImageBlob(this).catch(console.error);
};
refreshData = () => {
refreshData(this).catch(console.error);
};
private _handleError() {
this.resourceController.updateState({
errorMessage: 'Failed to download image!',
});
}
private _isLargeImage() {
const { width = 0, height = 0, size = 0 } = this.model.props;
const pixels = width * height;
return (
size >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_BYTES ||
pixels >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_PIXELS
);
}
private _shouldUseLod(blobUrl: string | null, zoom = this.gfx.viewport.zoom) {
return (
Boolean(blobUrl) &&
this._isLargeImage() &&
zoom <= ImageEdgelessBlockComponent.LOD_MAX_ZOOM
);
}
private _revokeLodThumbnail() {
if (!this._lodThumbnailUrl) {
return;
}
URL.revokeObjectURL(this._lodThumbnailUrl);
this._lodThumbnailUrl = null;
}
private _resetLodSource(blobUrl: string | null) {
if (this._lodSourceUrl === blobUrl) {
return;
}
this._lodGenerationToken += 1;
this._lodGeneratingSourceUrl = null;
this._lodSourceUrl = blobUrl;
this._revokeLodThumbnail();
}
private _createImageElement(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load image'));
image.src = src;
});
}
private _createThumbnailBlob(image: HTMLImageElement) {
const maxEdge = ImageEdgelessBlockComponent.LOD_THUMBNAIL_MAX_EDGE;
const longestEdge = Math.max(image.naturalWidth, image.naturalHeight);
const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
const targetWidth = Math.max(1, Math.round(image.naturalWidth * scale));
const targetHeight = Math.max(1, Math.round(image.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.resolve<Blob | null>(null);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'low';
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
return new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve);
});
}
private _ensureLodThumbnail(blobUrl: string) {
if (
this._lodThumbnailUrl ||
this._lodGeneratingSourceUrl === blobUrl ||
!this._shouldUseLod(blobUrl)
) {
return;
}
const token = ++this._lodGenerationToken;
this._lodGeneratingSourceUrl = blobUrl;
void this._createImageElement(blobUrl)
.then(image => this._createThumbnailBlob(image))
.then(blob => {
if (!blob || token !== this._lodGenerationToken || !this.isConnected) {
return;
}
const thumbnailUrl = URL.createObjectURL(blob);
if (token !== this._lodGenerationToken || !this.isConnected) {
URL.revokeObjectURL(thumbnailUrl);
return;
}
this._revokeLodThumbnail();
this._lodThumbnailUrl = thumbnailUrl;
if (this._shouldUseLod(this.blobUrl)) {
this.requestUpdate();
}
})
.catch(err => {
if (token !== this._lodGenerationToken || !this.isConnected) {
return;
}
console.error(err);
})
.finally(() => {
if (token === this._lodGenerationToken) {
this._lodGeneratingSourceUrl = null;
}
});
}
private _updateLodFromViewport(zoom: number) {
const shouldUseLod = this._shouldUseLod(this.blobUrl, zoom);
if (shouldUseLod === this._lastShouldUseLod) {
return;
}
this._lastShouldUseLod = shouldUseLod;
if (shouldUseLod && this.blobUrl) {
this._ensureLodThumbnail(this.blobUrl);
}
this.requestUpdate();
}
override connectedCallback() {
super.connectedCallback();
this.contentEditable = 'false';
this.resourceController.setEngine(this.std.store.blobSync);
this.disposables.add(this.resourceController.subscribe());
this.disposables.add(this.resourceController);
this.disposables.add(
this.model.props.sourceId$.subscribe(() => {
this._resetLodSource(null);
this.refreshData();
})
);
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
this._updateLodFromViewport(zoom);
})
);
this._lastShouldUseLod = this._shouldUseLod(this.blobUrl);
}
override disconnectedCallback() {
this._lodGenerationToken += 1;
this._lodGeneratingSourceUrl = null;
this._lodSourceUrl = null;
this._revokeLodThumbnail();
super.disconnectedCallback();
}
override renderGfxBlock() {
const blobUrl = this.blobUrl;
const { rotate, size: rawSize, caption = 'Image' } = this.model.props;
const size = rawSize ?? 0;
this._resetLodSource(blobUrl);
const containerStyleMap = styleMap({
display: 'flex',
position: 'relative',
width: '100%',
height: '100%',
transform: `rotate(${rotate}deg)`,
transformOrigin: 'center',
});
const resovledState = this.resourceController.resolveStateWith({
loadingIcon: LoadingIcon({
strokeColor: cssVarV2('button/pureWhiteText'),
ringColor: cssVarV2('loading/imageLoadingLayer', '#ffffff8f'),
}),
errorIcon: BrokenImageIcon(),
icon: ImageIcon(),
title: 'Image',
description: formatSize(size),
});
const { loading, icon, description, error, needUpload } = resovledState;
const shouldUseLod = this._shouldUseLod(blobUrl);
if (shouldUseLod && blobUrl) {
this._ensureLodThumbnail(blobUrl);
}
this._lastShouldUseLod = shouldUseLod;
const imageUrl =
shouldUseLod && this._lodThumbnailUrl ? this._lodThumbnailUrl : blobUrl;
return html`
<div class="affine-image-container" style=${containerStyleMap}>
${when(
blobUrl,
() => html`
<div class="resizable-img">
<img
class="drag-target"
draggable="false"
loading="lazy"
src=${imageUrl ?? ''}
alt=${caption}
@error=${this._handleError}
/>
</div>
${when(loading, () => html`<div class="loading">${icon}</div>`)}
${when(
Boolean(error && description),
() =>
html`<affine-resource-status
class="affine-image-status"
.message=${description}
.needUpload=${needUpload}
.action=${() =>
needUpload
? this.resourceController.upload()
: this.refreshData()}
></affine-resource-status>`
)}
`,
() =>
html`<affine-image-fallback-card
.state=${resovledState}
></affine-image-fallback-card>`
)}
<affine-block-selection .block=${this}></affine-block-selection>
</div>
<block-caption-editor></block-caption-editor>
${Object.values(this.widgets)}
`;
}
@query('block-caption-editor')
accessor captionEditor!: BlockCaptionEditor | null;
@query('.resizable-img')
accessor resizableImg!: HTMLDivElement;
}
export const ImageEdgelessBlockInteraction = GfxViewInteractionExtension(
ImageBlockSchema.model.flavour,
{
resizeConstraint: {
lockRatio: true,
},
}
);
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-image': ImageEdgelessBlockComponent;
}
}

View File

@@ -0,0 +1,105 @@
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
getClosestBlockComponentByElement,
getModelByElement,
} from '@blocksuite/affine-shared/utils';
import type { BlockComponent, PointerEventState } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
export class ImageResizeManager {
private _activeComponent: BlockComponent | null = null;
private _dragMoveTarget = 'right';
private _imageCenterX = 0;
private _imageContainer: HTMLElement | null = null;
private _zoom = 1;
onEnd() {
if (!this._activeComponent || !this._imageContainer) {
return;
}
const dragModel = getModelByElement(this._activeComponent);
dragModel?.store.captureSync();
const { width, height } = this._imageContainer.getBoundingClientRect();
dragModel?.store.updateBlock(dragModel, {
width: width / this._zoom,
height: height / this._zoom,
});
}
onMove(e: PointerEventState) {
const activeComponent = this._activeComponent;
const activeImgContainer = this._imageContainer;
if (!activeComponent || !activeImgContainer) {
return;
}
const activeImg = activeComponent.querySelector('img');
if (!activeImg) {
return;
}
let width = 0;
if (this._dragMoveTarget === 'right') {
width = (e.raw.pageX - this._imageCenterX) * 2;
} else {
width = (this._imageCenterX - e.raw.pageX) * 2;
}
const MIN_WIDTH = 50;
if (width < MIN_WIDTH) {
width = MIN_WIDTH;
}
if (width > activeComponent.getBoundingClientRect().width) {
width = activeComponent.getBoundingClientRect().width;
}
const height = width * (activeImg.naturalHeight / activeImg.naturalWidth);
const containerRect = activeImgContainer.getBoundingClientRect();
if (containerRect.width === width && containerRect.height === height)
return;
requestAnimationFrame(() => {
activeImgContainer.style.width = (width / this._zoom).toFixed(2) + 'px';
});
}
onStart(e: PointerEventState) {
const eventTarget = e.raw.target as HTMLElement;
this._activeComponent = getClosestBlockComponentByElement(
eventTarget
) as BlockComponent;
const rootComponent = getClosestRootBlockComponent(this._activeComponent);
if (
rootComponent &&
rootComponent.std.get(DocModeProvider).getEditorMode() === 'edgeless'
) {
const viewport = rootComponent.std.get(GfxControllerIdentifier).viewport;
this._zoom = viewport.zoom;
} else {
this._zoom = 1;
}
this._imageContainer = eventTarget.closest('.resizable-img');
if (!this._imageContainer) {
return;
}
const rect = this._imageContainer.getBoundingClientRect() as DOMRect;
this._imageCenterX = rect.left + rect.width / 2;
if (eventTarget.className.includes('right')) {
this._dragMoveTarget = 'right';
} else {
this._dragMoveTarget = 'left';
}
}
}
function getClosestRootBlockComponent(el: HTMLElement): BlockComponent | null {
return el.closest('affine-edgeless-root, affine-page-root');
}

View File

@@ -0,0 +1,46 @@
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import { FileDropConfigExtension } from '@blocksuite/affine-components/drop-indicator';
import { ImageBlockSchema, MAX_IMAGE_WIDTH } from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import {
isInsideEdgelessEditor,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { addImages, addSiblingImageBlocks } from './utils.js';
export const ImageDropOption = FileDropConfigExtension({
flavour: ImageBlockSchema.model.flavour,
onDrop: ({ files, targetModel, placement, point, std }) => {
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (!imageFiles.length) return false;
if (targetModel && !matchModels(targetModel, [SurfaceBlockModel])) {
addSiblingImageBlocks(std, imageFiles, targetModel, placement).catch(
console.error
);
return true;
}
if (isInsideEdgelessEditor(std.host)) {
const gfx = std.get(GfxControllerIdentifier);
point = gfx.viewport.toViewCoordFromClientCoord(point);
addImages(std, files, { point, maxWidth: MAX_IMAGE_WIDTH })
.then(() => {
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:drop',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'image',
});
})
.catch(console.error);
return true;
}
return false;
},
});

View File

@@ -0,0 +1,29 @@
import { ImageBlockSchema } from '@blocksuite/affine-model';
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { imageSlashMenuConfig } from './configs/slash-menu';
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
import { ImageEdgelessBlockInteraction } from './image-edgeless-block';
import { ImageDropOption } from './image-service';
const flavour = ImageBlockSchema.model.flavour;
export const ImageBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
const parent = model.store.getParent(model.id);
if (parent?.flavour === 'affine:surface') {
return literal`affine-edgeless-image`;
}
return literal`affine-image`;
}),
ImageDropOption,
ImageEdgelessBlockInteraction,
createBuiltinToolbarConfigExtension(flavour),
SlashMenuConfigExtension(flavour, imageSlashMenuConfig),
].flat();

View File

@@ -0,0 +1,11 @@
export * from './adapters';
export * from './commands';
export * from './edgeless-clipboard-config';
export * from './image-block';
export * from './image-edgeless-block';
export * from './image-service';
export * from './image-spec';
export * from './turbo/image-layout-handler';
export * from './turbo/image-painter.worker';
export { addImages, addSiblingImageBlocks, downloadImageBlob } from './utils';
export { ImageSelection } from '@blocksuite/affine-shared/selection';

View File

@@ -0,0 +1,26 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { toGfxBlockComponent } from '@blocksuite/std';
import { css } from 'lit';
import { ImagePlaceholderBlockComponent } from './page.js';
export class ImageEdgelessPlaceholderBlockComponent extends toGfxBlockComponent(
ImagePlaceholderBlockComponent
) {
static override styles = css`
affine-edgeless-placeholder-preview-image
.affine-placeholder-preview-container {
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
}
`;
override renderGfxBlock(): unknown {
return super.renderGfxBlock();
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-placeholder-preview-image': ImageEdgelessPlaceholderBlockComponent;
}
}

View File

@@ -0,0 +1,58 @@
import type { ImageBlockModel } from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ImageIcon } from '@blocksuite/icons/lit';
import { BlockComponent } from '@blocksuite/std';
import { css, html } from 'lit';
export class ImagePlaceholderBlockComponent extends BlockComponent<ImageBlockModel> {
static override styles = css`
.affine-placeholder-preview-container {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 4px;
box-sizing: border-box;
border-radius: 8px;
background-color: ${unsafeCSSVarV2(
'layer/background/overlayPanel',
'#FBFBFC'
)};
}
.placeholder-preview-content {
padding: 4px 16px;
display: flex;
gap: 8px;
}
.placeholder-preview-content > svg {
color: ${unsafeCSSVarV2('icon/primary', '#77757D')};
}
.placeholder-preview-content > .text {
color: var(--affine-text-primary-color);
color: ${unsafeCSSVarV2('text/primary', '#121212')};
font-size: 14px;
line-height: 24px;
}
`;
override renderBlock() {
return html`<div
class="affine-placeholder-preview-container"
contenteditable="false"
>
<div class="placeholder-preview-content">
${ImageIcon({ width: '24px', height: '24px' })}
<span class="text">Image Block</span>
</div>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-placeholder-preview-image': ImagePlaceholderBlockComponent;
}
}

View File

@@ -0,0 +1,18 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { ImageBlockSchemaExtension } from '@blocksuite/affine-model';
import { ImageSelectionExtension } from '@blocksuite/affine-shared/selection';
import { ImageBlockAdapterExtensions } from './adapters/extension';
export class ImageStoreExtension extends StoreExtensionProvider {
override name = 'affine-image-block';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register([ImageBlockSchemaExtension, ImageSelectionExtension]);
context.register(ImageBlockAdapterExtensions);
}
}

View File

@@ -0,0 +1,71 @@
import type { Rect } from '@blocksuite/affine-gfx-turbo-renderer';
import {
BlockLayoutHandlerExtension,
BlockLayoutHandlersIdentifier,
} from '@blocksuite/affine-gfx-turbo-renderer';
import type { Container } from '@blocksuite/global/di';
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
import { clientToModelCoord, type ViewportRecord } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { ImageLayout } from './image-painter.worker';
export class ImageLayoutHandlerExtension extends BlockLayoutHandlerExtension<ImageLayout> {
readonly blockType = 'affine:image';
static override setup(di: Container) {
di.addImpl(
BlockLayoutHandlersIdentifier('image'),
ImageLayoutHandlerExtension
);
}
override queryLayout(
model: BlockModel,
host: EditorHost,
viewportRecord: ViewportRecord
): ImageLayout | null {
const component = host.std.view.getBlock(
model.id
) as GfxBlockComponent | null;
if (!component) return null;
const imageContainer = component.querySelector('.affine-image-container');
if (!imageContainer) return null;
const resizableImg = component.querySelector(
'.resizable-img'
) as HTMLElement;
if (!resizableImg) return null;
const { zoom, viewScale } = viewportRecord;
const rect = resizableImg.getBoundingClientRect();
const [modelX, modelY] = clientToModelCoord(viewportRecord, [
rect.x,
rect.y,
]);
const imageLayout: ImageLayout = {
type: 'affine:image',
blockId: model.id,
rect: {
x: modelX,
y: modelY,
w: rect.width / zoom / viewScale,
h: rect.height / zoom / viewScale,
},
};
return imageLayout;
}
calculateBound(layout: ImageLayout) {
const rect: Rect = layout.rect;
return {
rect,
subRects: [rect],
};
}
}

View File

@@ -0,0 +1,56 @@
import type {
BlockLayout,
BlockLayoutPainter,
} from '@blocksuite/affine-gfx-turbo-renderer';
import { BlockLayoutPainterExtension } from '@blocksuite/affine-gfx-turbo-renderer/painter';
export interface ImageLayout extends BlockLayout {
type: 'affine:image';
rect: {
x: number;
y: number;
w: number;
h: number;
};
}
function isImageLayout(layout: BlockLayout): layout is ImageLayout {
return layout.type === 'affine:image';
}
class ImageLayoutPainter implements BlockLayoutPainter {
paint(
ctx: OffscreenCanvasRenderingContext2D,
layout: BlockLayout,
layoutBaseX: number,
layoutBaseY: number
): void {
if (!isImageLayout(layout)) {
console.warn(
'Expected image layout but received different format:',
layout
);
return;
}
// For now, just paint a white rectangle
const x = layout.rect.x - layoutBaseX;
const y = layout.rect.y - layoutBaseY;
const width = layout.rect.w;
const height = layout.rect.h;
// Draw a white rectangle with border
ctx.fillStyle = 'white';
ctx.fillRect(x, y, width, height);
// Add a border
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
}
}
export const ImageLayoutPainterExtension = BlockLayoutPainterExtension(
'affine:image',
ImageLayoutPainter
);

View File

@@ -0,0 +1,433 @@
import { autoResizeElementsCommand } from '@blocksuite/affine-block-surface';
import { toast } from '@blocksuite/affine-components/toast';
import {
type AttachmentBlockProps,
type ImageBlockModel,
type ImageBlockProps,
ImageBlockSchema,
} from '@blocksuite/affine-model';
import {
FileSizeLimitProvider,
NativeClipboardProvider,
} from '@blocksuite/affine-shared/services';
import {
convertToPng,
formatSize,
getBlockProps,
isInsidePageEditor,
readImageSize,
transformModel,
withTempBlobData,
} from '@blocksuite/affine-shared/utils';
import { Bound, type IVec, Vec } from '@blocksuite/global/gfx';
import { BlockSelection, type BlockStdScope } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import {
SURFACE_IMAGE_CARD_HEIGHT,
SURFACE_IMAGE_CARD_WIDTH,
} from './components/image-block-fallback';
import type { ImageBlockComponent } from './image-block';
import type { ImageEdgelessBlockComponent } from './image-edgeless-block';
const DEFAULT_ATTACHMENT_NAME = 'affine-attachment';
async function getImageBlob(model: ImageBlockModel) {
const sourceId = model.props.sourceId$.peek();
if (!sourceId) return null;
const doc = model.store;
let blob = await doc.blobSync.get(sourceId);
if (!blob) return null;
if (!blob.type) {
const buffer = await blob.arrayBuffer();
const FileType = await import('file-type');
const fileType = await FileType.fileTypeFromBuffer(buffer);
blob = new Blob([buffer], { type: fileType?.mime });
}
if (!blob.type.startsWith('image/')) return null;
return blob;
}
export async function refreshData(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {
await block.resourceController.refreshUrlWith();
}
export async function downloadImageBlob(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {
const { host, blobUrl, resourceController } = block;
if (!blobUrl) {
toast(host, 'Failed to download image!');
return;
}
if (resourceController.state$.peek().downloading) {
toast(host, 'Download in progress...');
return;
}
resourceController.updateState({ downloading: true });
toast(host, 'Downloading image...');
const tmpLink = document.createElement('a');
const event = new MouseEvent('click');
tmpLink.download = 'image';
tmpLink.href = blobUrl;
tmpLink.dispatchEvent(event);
tmpLink.remove();
resourceController.updateState({ downloading: false });
}
export async function resetImageSize(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {
const { model } = block;
const blob = await getImageBlob(model);
if (!blob) {
console.error('Failed to get image blob');
return;
}
const imageSize = await readImageSize(blob);
const bound = model.elementBound;
bound.w = imageSize.width;
bound.h = imageSize.height;
const xywh = bound.serialize();
const props: Partial<ImageBlockProps> = { ...imageSize, xywh };
block.store.updateBlock(model, props);
}
export async function copyImageBlob(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {
const { host, model, std } = block;
let blob = await getImageBlob(model);
if (!blob) {
console.error('Failed to get image blob');
return;
}
let copied = false;
try {
// Copies the image as PNG in Electron.
const copyAsPNG = std.getOptional(NativeClipboardProvider)?.copyAsPNG;
if (copyAsPNG) {
copied = await copyAsPNG(await blob.arrayBuffer());
}
// The current clipboard only supports the `image/png` image format.
// The `ClipboardItem.supports('image/svg+xml')` is not currently used,
// because when pasting, the content is not read correctly.
//
// https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem
// https://alexharri.com/blog/clipboard
if (!copied) {
if (blob.type !== 'image/png') {
blob = await convertToPng(blob);
if (!blob) {
console.error('Failed to convert blob to PNG');
return;
}
}
if (!globalThis.isSecureContext) {
console.error(
'Clipboard API is not available in insecure context',
blob.type,
blob
);
return;
}
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob }),
]);
}
toast(host, 'Copied image to clipboard');
} catch (error) {
console.error(error);
}
}
/**
* Turn the image block into a attachment block.
*/
export async function turnImageIntoCardView(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {
const doc = block.store;
if (!doc.schema.flavourSchemaMap.has('affine:attachment')) {
console.error('The attachment flavour is not supported!');
return;
}
const model = block.model;
const sourceId = model.props.sourceId$.peek();
const blob = await getImageBlob(model);
if (!sourceId || !blob) {
console.error('Image data not available');
return;
}
const { saveImageData, getAttachmentData } = withTempBlobData();
saveImageData(sourceId, {
width: model.props.width,
height: model.props.height,
});
const attachmentConvertData = getAttachmentData(sourceId);
const attachmentProp: Partial<AttachmentBlockProps> = {
sourceId,
name: DEFAULT_ATTACHMENT_NAME,
size: blob.size,
type: blob.type,
caption: model.props.caption,
...attachmentConvertData,
};
transformModel(model, 'affine:attachment', attachmentProp);
}
export function shouldResizeImage(node: Node, target: EventTarget | null) {
return !!(
target &&
target instanceof HTMLElement &&
node.contains(target) &&
target.classList.contains('resize')
);
}
function hasExceeded(
std: BlockStdScope,
files: File[],
maxFileSize = std.get(FileSizeLimitProvider).maxFileSize
) {
const exceeded = files.some(file => file.size > maxFileSize);
if (exceeded) {
const size = formatSize(maxFileSize);
toast(std.host, `You can only upload files less than ${size}`);
}
return exceeded;
}
async function buildPropsWith(std: BlockStdScope, file: File) {
const { size } = file;
const [imageSize, sourceId] = await Promise.all([
readImageSize(file),
std.store.blobSync.set(file),
]);
if (!(imageSize.width * imageSize.height)) {
toast(std.host, 'Failed to read image size, please try another image');
throw new Error('Failed to read image size');
}
return { size, sourceId, ...imageSize } satisfies Partial<ImageBlockProps>;
}
export async function addSiblingImageBlocks(
std: BlockStdScope,
files: File[],
targetModel: BlockModel,
placement: 'after' | 'before' = 'after'
) {
files = files.filter(file => file.type.startsWith('image/'));
if (!files.length) return [];
if (hasExceeded(std, files)) return [];
const flavour = ImageBlockSchema.model.flavour;
const propsArray = await Promise.all(
files.map(file => buildPropsWith(std, file))
);
const blockIds = std.store.addSiblingBlocks(
targetModel,
propsArray.map(props => ({ ...props, flavour })),
placement
);
return blockIds;
}
export async function addImageBlocks(
std: BlockStdScope,
files: File[],
parent?: BlockModel | string | null,
parentIndex?: number
) {
files = files.filter(file => file.type.startsWith('image/'));
if (!files.length) return [];
if (hasExceeded(std, files)) return [];
const flavour = ImageBlockSchema.model.flavour;
const propsArray = await Promise.all(
files.map(file => buildPropsWith(std, file))
);
const blocks = propsArray.map(blockProps => ({ flavour, blockProps }));
const blockIds = std.store.addBlocks(blocks, parent, parentIndex);
return blockIds;
}
export async function addImages(
std: BlockStdScope,
files: File[],
options: {
point?: IVec;
maxWidth?: number;
shouldTransformPoint?: boolean; // determines whether we should use `toModelCoord` to convert the point
}
): Promise<string[]> {
files = files.filter(file => file.type.startsWith('image/'));
if (!files.length) return [];
if (hasExceeded(std, files)) return [];
const flavour = ImageBlockSchema.model.flavour;
const propsArray = await Promise.all(
files.map(file => buildPropsWith(std, file))
);
const gfx = std.get(GfxControllerIdentifier);
const isMultiple = propsArray.length > 1;
const inTopLeft = isMultiple;
const gap = 32;
const { point, maxWidth, shouldTransformPoint = true } = options;
let { x, y } = gfx.viewport.center;
if (point) {
if (shouldTransformPoint) {
[x, y] = gfx.viewport.toModelCoord(...point);
} else {
[x, y] = point;
}
}
const xy = [x, y];
const blocks = propsArray.map((props, i) => {
// If maxWidth is provided, limit the width of the image to maxWidth
// Otherwise, use the original width
if (maxWidth) {
const p = props.height / props.width;
props.width = Math.min(props.width, maxWidth);
props.height = props.width * p;
}
const center = Vec.addScalar(xy, i * gap);
const index = gfx.layer.generateIndex();
const { width, height } = props;
const xywh = calcBoundByOrigin(
center,
inTopLeft,
width,
height
).serialize();
return {
flavour,
blockProps: {
...props,
width,
height,
xywh,
index,
},
};
});
const blockIds = std.store.addBlocks(blocks, gfx.surface);
gfx.selection.set({
elements: blockIds,
editing: false,
});
if (isMultiple) {
std.command.exec(autoResizeElementsCommand);
}
return blockIds;
}
export function calcBoundByOrigin(
point: IVec,
inTopLeft = false,
width = SURFACE_IMAGE_CARD_WIDTH,
height = SURFACE_IMAGE_CARD_HEIGHT
) {
return inTopLeft
? new Bound(point[0], point[1], width, height)
: Bound.fromCenter(point, width, height);
}
export function duplicate(block: ImageBlockComponent) {
const model = block.model;
const blockProps = getBlockProps(model);
const {
width: _width,
height: _height,
xywh: _xywh,
rotate: _rotate,
zIndex: _zIndex,
...duplicateProps
} = blockProps;
const { store } = model;
const parent = store.getParent(model);
if (!parent) {
console.error(`Parent not found for block(${model.flavour}) ${model.id}`);
return;
}
const index = parent?.children.indexOf(model);
const duplicateId = store.addBlock(
model.flavour,
duplicateProps,
parent,
index + 1
);
const editorHost = block.host;
editorHost.updateComplete
.then(() => {
const { selection } = editorHost;
selection.setGroup('note', [
selection.create(BlockSelection, {
blockId: duplicateId,
}),
]);
if (isInsidePageEditor(editorHost)) {
const duplicateElement = editorHost.view.getBlock(duplicateId);
if (duplicateElement) {
duplicateElement.scrollIntoView(true);
}
}
})
.catch(console.error);
}

View File

@@ -0,0 +1,27 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { EdgelessClipboardImageConfig } from './edgeless-clipboard-config';
import { effects } from './effects';
import { ImageEdgelessBlockInteraction } from './image-edgeless-block';
import { ImageBlockSpec } from './image-spec';
export class ImageViewExtension extends ViewExtensionProvider {
override name = 'affine-image-block';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(ImageBlockSpec);
if (this.isEdgeless(context.scope)) {
context.register(EdgelessClipboardImageConfig);
context.register(ImageEdgelessBlockInteraction);
}
}
}

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../note" },
{ "path": "../surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../gfx/turbo-renderer" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../widgets/slash-menu" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}