snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (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 4s
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 / 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 / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (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 / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
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 / 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 / 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 @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 / 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 (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (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 (5, 5) (push) Has been skipped
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 / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (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 (x86_64-apple-darwin) (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
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (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 4s
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 / 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 / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (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 / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
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 / 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 / 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 @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 / 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 (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (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 (5, 5) (push) Has been skipped
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 / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (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 (x86_64-apple-darwin) (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
This commit is contained in:
43
blocksuite/affine/blocks/image/package.json
Normal file
43
blocksuite/affine/blocks/image/package.json
Normal 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"
|
||||
}
|
||||
11
blocksuite/affine/blocks/image/src/adapters/extension.ts
Normal file
11
blocksuite/affine/blocks/image/src/adapters/extension.ts
Normal 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,
|
||||
];
|
||||
90
blocksuite/affine/blocks/image/src/adapters/html.ts
Normal file
90
blocksuite/affine/blocks/image/src/adapters/html.ts
Normal 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
|
||||
);
|
||||
3
blocksuite/affine/blocks/image/src/adapters/index.ts
Normal file
3
blocksuite/affine/blocks/image/src/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.js';
|
||||
66
blocksuite/affine/blocks/image/src/adapters/markdown.ts
Normal file
66
blocksuite/affine/blocks/image/src/adapters/markdown.ts
Normal 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
|
||||
);
|
||||
82
blocksuite/affine/blocks/image/src/adapters/notion-html.ts
Normal file
82
blocksuite/affine/blocks/image/src/adapters/notion-html.ts
Normal 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);
|
||||
89
blocksuite/affine/blocks/image/src/adapters/utils.ts
Normal file
89
blocksuite/affine/blocks/image/src/adapters/utils.ts
Normal 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();
|
||||
}
|
||||
1
blocksuite/affine/blocks/image/src/commands/index.ts
Normal file
1
blocksuite/affine/blocks/image/src/commands/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { insertImagesCommand } from './insert-images.js';
|
||||
40
blocksuite/affine/blocks/image/src/commands/insert-images.ts
Normal file
40
blocksuite/affine/blocks/image/src/commands/insert-images.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
32
blocksuite/affine/blocks/image/src/configs/slash-menu.ts
Normal file
32
blocksuite/affine/blocks/image/src/configs/slash-menu.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
222
blocksuite/affine/blocks/image/src/configs/toolbar.ts
Normal file
222
blocksuite/affine/blocks/image/src/configs/toolbar.ts
Normal 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,
|
||||
}),
|
||||
];
|
||||
};
|
||||
20
blocksuite/affine/blocks/image/src/configs/tooltips.ts
Normal file
20
blocksuite/affine/blocks/image/src/configs/tooltips.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
21
blocksuite/affine/blocks/image/src/effects.ts
Normal file
21
blocksuite/affine/blocks/image/src/effects.ts
Normal 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
|
||||
);
|
||||
}
|
||||
204
blocksuite/affine/blocks/image/src/image-block.ts
Normal file
204
blocksuite/affine/blocks/image/src/image-block.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
375
blocksuite/affine/blocks/image/src/image-edgeless-block.ts
Normal file
375
blocksuite/affine/blocks/image/src/image-edgeless-block.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
105
blocksuite/affine/blocks/image/src/image-resize-manager.ts
Normal file
105
blocksuite/affine/blocks/image/src/image-resize-manager.ts
Normal 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');
|
||||
}
|
||||
46
blocksuite/affine/blocks/image/src/image-service.ts
Normal file
46
blocksuite/affine/blocks/image/src/image-service.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
29
blocksuite/affine/blocks/image/src/image-spec.ts
Normal file
29
blocksuite/affine/blocks/image/src/image-spec.ts
Normal 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();
|
||||
11
blocksuite/affine/blocks/image/src/index.ts
Normal file
11
blocksuite/affine/blocks/image/src/index.ts
Normal 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';
|
||||
26
blocksuite/affine/blocks/image/src/preview-image/edgeless.ts
Normal file
26
blocksuite/affine/blocks/image/src/preview-image/edgeless.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
58
blocksuite/affine/blocks/image/src/preview-image/page.ts
Normal file
58
blocksuite/affine/blocks/image/src/preview-image/page.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
blocksuite/affine/blocks/image/src/store.ts
Normal file
18
blocksuite/affine/blocks/image/src/store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
433
blocksuite/affine/blocks/image/src/utils.ts
Normal file
433
blocksuite/affine/blocks/image/src/utils.ts
Normal 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);
|
||||
}
|
||||
27
blocksuite/affine/blocks/image/src/view.ts
Normal file
27
blocksuite/affine/blocks/image/src/view.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
blocksuite/affine/blocks/image/tsconfig.json
Normal file
22
blocksuite/affine/blocks/image/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user