mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
refactor(editor): extract image block (#9309)
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"@blocksuite/affine-block-attachment": "workspace:*",
|
||||
"@blocksuite/affine-block-bookmark": "workspace:*",
|
||||
"@blocksuite/affine-block-embed": "workspace:*",
|
||||
"@blocksuite/affine-block-image": "workspace:*",
|
||||
"@blocksuite/affine-block-list": "workspace:*",
|
||||
"@blocksuite/affine-block-paragraph": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
EmbedSyncedDocBlockHtmlAdapterExtension,
|
||||
EmbedYoutubeBlockHtmlAdapterExtension,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { ImageBlockHtmlAdapterExtension } from '@blocksuite/affine-block-image';
|
||||
import { ListBlockHtmlAdapterExtension } from '@blocksuite/affine-block-list';
|
||||
import { ParagraphBlockHtmlAdapterExtension } from '@blocksuite/affine-block-paragraph';
|
||||
|
||||
import { CodeBlockHtmlAdapterExtension } from '../../../code-block/adapters/html.js';
|
||||
import { DatabaseBlockHtmlAdapterExtension } from '../../../database-block/adapters/html.js';
|
||||
import { DividerBlockHtmlAdapterExtension } from '../../../divider-block/adapters/html.js';
|
||||
import { ImageBlockHtmlAdapterExtension } from '../../../image-block/adapters/html.js';
|
||||
import { RootBlockHtmlAdapterExtension } from '../../../root-block/adapters/html.js';
|
||||
|
||||
export const defaultBlockHtmlAdapterMatchers = [
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
embedSyncedDocBlockMarkdownAdapterMatcher,
|
||||
embedYoutubeBlockMarkdownAdapterMatcher,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { imageBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-image';
|
||||
import { listBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-list';
|
||||
import { paragraphBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-paragraph';
|
||||
|
||||
import { codeBlockMarkdownAdapterMatcher } from '../../../code-block/adapters/markdown.js';
|
||||
import { databaseBlockMarkdownAdapterMatcher } from '../../../database-block/adapters/markdown.js';
|
||||
import { dividerBlockMarkdownAdapterMatcher } from '../../../divider-block/adapters/markdown.js';
|
||||
import { imageBlockMarkdownAdapterMatcher } from '../../../image-block/adapters/markdown.js';
|
||||
import { latexBlockMarkdownAdapterMatcher } from '../../../latex-block/adapters/markdown.js';
|
||||
import { rootBlockMarkdownAdapterMatcher } from '../../../root-block/adapters/markdown.js';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
embedLoomBlockNotionHtmlAdapterMatcher,
|
||||
embedYoutubeBlockNotionHtmlAdapterMatcher,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { imageBlockNotionHtmlAdapterMatcher } from '@blocksuite/affine-block-image';
|
||||
import { listBlockNotionHtmlAdapterMatcher } from '@blocksuite/affine-block-list';
|
||||
import { paragraphBlockNotionHtmlAdapterMatcher } from '@blocksuite/affine-block-paragraph';
|
||||
import type { BlockNotionHtmlAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
@@ -13,7 +14,6 @@ import type { BlockNotionHtmlAdapterMatcher } from '@blocksuite/affine-shared/ad
|
||||
import { codeBlockNotionHtmlAdapterMatcher } from '../../../code-block/adapters/notion-html.js';
|
||||
import { databaseBlockNotionHtmlAdapterMatcher } from '../../../database-block/adapters/notion-html.js';
|
||||
import { dividerBlockNotionHtmlAdapterMatcher } from '../../../divider-block/adapters/notion-html.js';
|
||||
import { imageBlockNotionHtmlAdapterMatcher } from '../../../image-block/adapters/notion-html.js';
|
||||
import { latexBlockNotionHtmlAdapterMatcher } from '../../../latex-block/adapters/notion-html.js';
|
||||
import { rootBlockNotionHtmlAdapterMatcher } from '../../../root-block/adapters/notion-html.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AttachmentBlockSpec } from '@blocksuite/affine-block-attachment';
|
||||
import { BookmarkBlockSpec } from '@blocksuite/affine-block-bookmark';
|
||||
import { EmbedExtensions } from '@blocksuite/affine-block-embed';
|
||||
import { ImageBlockSpec } from '@blocksuite/affine-block-image';
|
||||
import { ListBlockSpec } from '@blocksuite/affine-block-list';
|
||||
import { ParagraphBlockSpec } from '@blocksuite/affine-block-paragraph';
|
||||
import { RichTextExtensions } from '@blocksuite/affine-components/rich-text';
|
||||
@@ -12,7 +13,6 @@ import { CodeBlockSpec } from '../code-block/code-block-spec.js';
|
||||
import { DataViewBlockSpec } from '../data-view-block/data-view-spec.js';
|
||||
import { DatabaseBlockSpec } from '../database-block/database-spec.js';
|
||||
import { DividerBlockSpec } from '../divider-block/divider-spec.js';
|
||||
import { ImageBlockSpec } from '../image-block/image-spec.js';
|
||||
import {
|
||||
EdgelessNoteBlockSpec,
|
||||
NoteBlockSpec,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
EmbedSyncedDocBlockSpec,
|
||||
EmbedYoutubeBlockSpec,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { ImageBlockSpec } from '@blocksuite/affine-block-image';
|
||||
import { ListBlockSpec } from '@blocksuite/affine-block-list';
|
||||
import { ParagraphBlockSpec } from '@blocksuite/affine-block-paragraph';
|
||||
|
||||
@@ -16,7 +17,6 @@ import { CodeBlockSpec } from '../../code-block/code-block-spec.js';
|
||||
import { DataViewBlockSpec } from '../../data-view-block/data-view-spec.js';
|
||||
import { DatabaseBlockSpec } from '../../database-block/database-spec.js';
|
||||
import { DividerBlockSpec } from '../../divider-block/divider-spec.js';
|
||||
import { ImageBlockSpec } from '../../image-block/image-spec.js';
|
||||
import {
|
||||
EdgelessNoteBlockSpec,
|
||||
NoteBlockSpec,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { effects as blockAttachmentEffects } from '@blocksuite/affine-block-attachment/effects';
|
||||
import { effects as blockBookmarkEffects } from '@blocksuite/affine-block-bookmark/effects';
|
||||
import { effects as blockEmbedEffects } from '@blocksuite/affine-block-embed/effects';
|
||||
import { effects as blockImageEffects } from '@blocksuite/affine-block-image/effects';
|
||||
import { effects as blockListEffects } from '@blocksuite/affine-block-list/effects';
|
||||
import { effects as blockParagraphEffects } from '@blocksuite/affine-block-paragraph/effects';
|
||||
import { effects as blockSurfaceEffects } from '@blocksuite/affine-block-surface/effects';
|
||||
@@ -63,14 +64,6 @@ import { DividerBlockComponent } from './divider-block/index.js';
|
||||
import type { insertEdgelessTextCommand } from './edgeless-text-block/commands/insert-edgeless-text.js';
|
||||
import { EdgelessTextBlockComponent } from './edgeless-text-block/index.js';
|
||||
import { FrameBlockComponent } from './frame-block/index.js';
|
||||
import { ImageBlockFallbackCard } from './image-block/components/image-block-fallback.js';
|
||||
import { ImageBlockPageComponent } from './image-block/components/page-image-block.js';
|
||||
import { effects as blockImageEffects } from './image-block/effects.js';
|
||||
import {
|
||||
ImageBlockComponent,
|
||||
type ImageBlockService,
|
||||
ImageEdgelessBlockComponent,
|
||||
} from './image-block/index.js';
|
||||
import { effects as blockLatexEffects } from './latex-block/effects.js';
|
||||
import { LatexBlockComponent } from './latex-block/index.js';
|
||||
import type { updateBlockType } from './note-block/commands/block-type.js';
|
||||
@@ -301,11 +294,9 @@ export function effects() {
|
||||
widgetCodeToolbarEffects();
|
||||
|
||||
customElements.define('affine-database-title', DatabaseTitle);
|
||||
customElements.define('affine-image', ImageBlockComponent);
|
||||
customElements.define('data-view-header-area-icon', IconCell);
|
||||
customElements.define('affine-database-link-cell', LinkCell);
|
||||
customElements.define('affine-database-link-cell-editing', LinkCellEditing);
|
||||
customElements.define('affine-edgeless-image', ImageEdgelessBlockComponent);
|
||||
customElements.define('data-view-header-area-text', HeaderAreaTextCell);
|
||||
customElements.define(
|
||||
'data-view-header-area-text-editing',
|
||||
@@ -326,9 +317,7 @@ export function effects() {
|
||||
customElements.define('edgeless-note-mask', EdgelessNoteMask);
|
||||
customElements.define('affine-edgeless-note', EdgelessNoteBlockComponent);
|
||||
customElements.define('affine-preview-root', PreviewRootBlockComponent);
|
||||
customElements.define('affine-page-image', ImageBlockPageComponent);
|
||||
customElements.define('affine-code', CodeBlockComponent);
|
||||
customElements.define('affine-image-fallback-card', ImageBlockFallbackCard);
|
||||
customElements.define('mini-mindmap-preview', MiniMindmapPreview);
|
||||
customElements.define('affine-frame', FrameBlockComponent);
|
||||
customElements.define('mini-mindmap-surface-block', MindmapSurfaceBlock);
|
||||
@@ -587,7 +576,6 @@ declare global {
|
||||
'affine:note': NoteBlockService;
|
||||
'affine:page': RootService;
|
||||
'affine:database': DatabaseBlockService;
|
||||
'affine:image': ImageBlockService;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
|
||||
import { ImageBlockHtmlAdapterExtension } from './html.js';
|
||||
import { ImageBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { ImageBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
|
||||
export const ImageBlockAdapterExtensions: ExtensionType[] = [
|
||||
ImageBlockHtmlAdapterExtension,
|
||||
ImageBlockMarkdownAdapterExtension,
|
||||
ImageBlockNotionHtmlAdapterExtension,
|
||||
];
|
||||
@@ -1,145 +0,0 @@
|
||||
import { ImageBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
FetchUtils,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import { getAssetName, nanoid } from '@blocksuite/store';
|
||||
|
||||
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) {
|
||||
let blobId = '';
|
||||
if (!FetchUtils.fetchable(imageURL)) {
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:image',
|
||||
props: {
|
||||
sourceId: blobId,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
},
|
||||
},
|
||||
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
|
||||
);
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.js';
|
||||
@@ -1,119 +0,0 @@
|
||||
import { ImageBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
FetchUtils,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import { getAssetName, nanoid } from '@blocksuite/store';
|
||||
|
||||
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;
|
||||
let blobId = '';
|
||||
const imageURL = 'url' in o.node ? o.node.url : '';
|
||||
if (!assets || !imageURL) {
|
||||
return;
|
||||
}
|
||||
if (!FetchUtils.fetchable(imageURL)) {
|
||||
const imageURLSplit = imageURL.split('/');
|
||||
while (imageURLSplit.length > 0) {
|
||||
const key = assets
|
||||
.getPathBlobIdMap()
|
||||
.get(decodeURIComponent(imageURLSplit.join('/')));
|
||||
if (key) {
|
||||
blobId = key;
|
||||
break;
|
||||
}
|
||||
imageURLSplit.shift();
|
||||
}
|
||||
} else {
|
||||
const res = await FetchUtils.fetchImage(
|
||||
imageURL,
|
||||
undefined,
|
||||
configs.get('imageProxy') as string
|
||||
);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
const clonedRes = res.clone();
|
||||
const file = new File(
|
||||
[await res.blob()],
|
||||
getFilenameFromContentDisposition(
|
||||
res.headers.get('Content-Disposition') ?? ''
|
||||
) ??
|
||||
(imageURL.split('/').at(-1) ?? 'image') +
|
||||
'.' +
|
||||
(res.headers.get('Content-Type')?.split('/').at(-1) ?? 'png'),
|
||||
{
|
||||
type: res.headers.get('Content-Type') ?? '',
|
||||
}
|
||||
);
|
||||
blobId = await sha(await clonedRes.arrayBuffer());
|
||||
assets?.getAssets().set(blobId, file);
|
||||
await assets?.writeToBlob(blobId);
|
||||
}
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:image',
|
||||
props: {
|
||||
sourceId: blobId,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
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
|
||||
);
|
||||
@@ -1,139 +0,0 @@
|
||||
import { ImageBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
FetchUtils,
|
||||
HastUtils,
|
||||
} 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';
|
||||
|
||||
async function processImageNode(
|
||||
imageURL: string,
|
||||
walkerContext: ASTWalkerContext<BlockSnapshot>,
|
||||
assets: AssetsManager,
|
||||
configs: Map<string, string>
|
||||
) {
|
||||
let blobId = '';
|
||||
if (!FetchUtils.fetchable(imageURL)) {
|
||||
const imageURLSplit = imageURL.split('/');
|
||||
while (imageURLSplit.length > 0) {
|
||||
const key = assets
|
||||
.getPathBlobIdMap()
|
||||
.get(decodeURIComponent(imageURLSplit.join('/')));
|
||||
if (key) {
|
||||
blobId = key;
|
||||
break;
|
||||
}
|
||||
imageURLSplit.shift();
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: ImageBlockSchema.model.flavour,
|
||||
props: {
|
||||
sourceId: blobId,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
|
||||
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 processImageNode(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 processImageNode(imageURL, walkerContext, assets, configs);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const ImageBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(imageBlockNotionHtmlAdapterMatcher);
|
||||
@@ -1,9 +0,0 @@
|
||||
import { getImageSelectionsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import type { BlockCommands } from '@blocksuite/block-std';
|
||||
|
||||
import { insertImagesCommand } from './insert-images.js';
|
||||
|
||||
export const commands: BlockCommands = {
|
||||
getImageSelections: getImageSelectionsCommand,
|
||||
insertImages: insertImagesCommand,
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { getImageFilesFromLocal } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
import { addSiblingImageBlock } from '../utils.js';
|
||||
|
||||
export const insertImagesCommand: Command<
|
||||
'selectedModels',
|
||||
'insertedImageIds',
|
||||
{ removeEmptyLine?: boolean; place?: 'after' | 'before' }
|
||||
> = (ctx, next) => {
|
||||
const { selectedModels, place, removeEmptyLine, std } = ctx;
|
||||
if (!selectedModels) return;
|
||||
|
||||
return next({
|
||||
insertedImageIds: getImageFilesFromLocal().then(imageFiles => {
|
||||
if (imageFiles.length === 0) return [];
|
||||
|
||||
if (selectedModels.length === 0) return [];
|
||||
|
||||
const targetModel =
|
||||
place === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
const imageService = std.getService('affine:image');
|
||||
if (!imageService) return [];
|
||||
|
||||
const maxFileSize = imageService.maxFileSize;
|
||||
|
||||
const result = addSiblingImageBlock(
|
||||
std.host,
|
||||
imageFiles,
|
||||
maxFileSize,
|
||||
targetModel,
|
||||
place
|
||||
);
|
||||
if (removeEmptyLine && targetModel.text?.length === 0) {
|
||||
std.doc.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
return result ?? [];
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import { humanFileSize } from '@blocksuite/affine-shared/utils';
|
||||
import { modelContext, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { FailedImageIcon, ImageIcon, LoadingIcon } from '../styles.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-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.affine-image-fallback-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background-color: var(--affine-background-secondary-color, #f4f4f5);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--affine-background-tertiary-color, #eee);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.affine-image-fallback-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--affine-placeholder-color);
|
||||
text-align: justify;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: var(--affine-line-height);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-image-card-size {
|
||||
overflow: hidden;
|
||||
padding-top: 12px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
text-overflow: ellipsis;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const { mode, loading, error, model } = this;
|
||||
|
||||
const isEdgeless = mode === 'edgeless';
|
||||
const width = isEdgeless
|
||||
? `${SURFACE_IMAGE_CARD_WIDTH}px`
|
||||
: `${NOTE_IMAGE_CARD_WIDTH}px`;
|
||||
const height = isEdgeless
|
||||
? `${SURFACE_IMAGE_CARD_HEIGHT}px`
|
||||
: `${NOTE_IMAGE_CARD_HEIGHT}px`;
|
||||
|
||||
const rotate = isEdgeless ? model.rotate : 0;
|
||||
|
||||
const cardStyleMap = styleMap({
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
transformOrigin: 'center',
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const titleIcon = loading
|
||||
? LoadingIcon
|
||||
: error
|
||||
? FailedImageIcon
|
||||
: ImageIcon;
|
||||
|
||||
const titleText = loading
|
||||
? 'Loading image...'
|
||||
: error
|
||||
? 'Image loading failed.'
|
||||
: 'Image';
|
||||
|
||||
const size =
|
||||
!!model.size && model.size > 0
|
||||
? humanFileSize(model.size, true, 0)
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div class="affine-image-fallback-card-container">
|
||||
<div
|
||||
class="affine-image-fallback-card drag-target"
|
||||
style=${cardStyleMap}
|
||||
>
|
||||
<div class="affine-image-fallback-card-content">
|
||||
${titleIcon}
|
||||
<span class="affine-image-fallback-card-title-text"
|
||||
>${titleText}</span
|
||||
>
|
||||
</div>
|
||||
<div class="affine-image-card-size">${size}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor error!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor loading!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor mode!: 'page' | 'edgeless';
|
||||
|
||||
@consume({ context: modelContext })
|
||||
accessor model!: ImageBlockModel;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-image-fallback-card': ImageBlockFallbackCard;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
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) {
|
||||
if (readonly) {
|
||||
return html`${styles}
|
||||
<div
|
||||
class="affine-page-selected-embed-rects-container resizable resizes"
|
||||
></div> `;
|
||||
}
|
||||
return html`
|
||||
${styles}
|
||||
<div class="affine-page-selected-embed-rects-container resizable resizes">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import type { BaseSelection, UIEventStateContext } from '@blocksuite/block-std';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { ImageBlockComponent } from '../image-block.js';
|
||||
import { ImageResizeManager } from '../image-resize-manager.js';
|
||||
import { shouldResizeImage } from '../utils.js';
|
||||
import { ImageSelectedRect } from './image-selected-rect.js';
|
||||
|
||||
export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
affine-page-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
affine-page-image .resizable-img {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
affine-page-image .resizable-img img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
private _isDragging = false;
|
||||
|
||||
private get _doc() {
|
||||
return this.block.doc;
|
||||
}
|
||||
|
||||
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('image'))
|
||||
.concat(
|
||||
selection.create('text', {
|
||||
from: {
|
||||
blockId,
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
this.block.bindHotKey({
|
||||
Escape: () => {
|
||||
selection.update(selList => {
|
||||
return selList.map(sel => {
|
||||
const current =
|
||||
sel.is('image') && sel.blockId === this.block.blockId;
|
||||
if (current) {
|
||||
return selection.create('block', { blockId: this.block.blockId });
|
||||
}
|
||||
return sel;
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
Delete: ctx => {
|
||||
if (this._host.doc.readonly || !this._isSelected) return;
|
||||
|
||||
addParagraph(ctx);
|
||||
this._doc.deleteBlock(this._model);
|
||||
return true;
|
||||
},
|
||||
Backspace: ctx => {
|
||||
if (this._host.doc.readonly || !this._isSelected) return;
|
||||
|
||||
addParagraph(ctx);
|
||||
this._doc.deleteBlock(this._model);
|
||||
return true;
|
||||
},
|
||||
Enter: ctx => {
|
||||
if (this._host.doc.readonly || !this._isSelected) 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('image')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const event = ctx.get('keyboardState');
|
||||
event.raw.preventDefault();
|
||||
|
||||
std.command
|
||||
.chain()
|
||||
.getNextBlock({ path: this.block.blockId })
|
||||
.inline((ctx, next) => {
|
||||
const { nextBlock } = ctx;
|
||||
if (!nextBlock) return;
|
||||
|
||||
return next({ focusBlock: nextBlock });
|
||||
})
|
||||
.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('image')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const event = ctx.get('keyboardState');
|
||||
event.raw.preventDefault();
|
||||
|
||||
std.command
|
||||
.chain()
|
||||
.getPrevBlock({ path: this.block.blockId })
|
||||
.inline((ctx, next) => {
|
||||
const { prevBlock } = ctx;
|
||||
if (!prevBlock) return;
|
||||
|
||||
return next({ focusBlock: prevBlock });
|
||||
})
|
||||
.focusBlockEnd()
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _handleError() {
|
||||
this.block.error = true;
|
||||
}
|
||||
|
||||
private _handleSelection() {
|
||||
const selection = this._host.selection;
|
||||
this._disposables.add(
|
||||
selection.slots.changed.on(selList => {
|
||||
this._isSelected = selList.some(
|
||||
sel => sel.blockId === this.block.blockId && sel.is('image')
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this._model.propsUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
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('image', { blockId: this.block.blockId }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
this.block.handleEvent(
|
||||
'click',
|
||||
() => {
|
||||
if (!this._isSelected) return;
|
||||
|
||||
selection.update(selList =>
|
||||
selList.filter(
|
||||
sel => !(sel.is('image') && 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;
|
||||
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._isSelected
|
||||
? ImageSelectedRect(this._doc.readonly)
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div class="resizable-img" style=${styleMap(imageSize)}>
|
||||
<img
|
||||
class="drag-target"
|
||||
src=${this.block.blobUrl ?? ''}
|
||||
draggable="false"
|
||||
@error=${this._handleError}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
${imageSelectedRect}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor _isSelected = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor block!: ImageBlockComponent;
|
||||
|
||||
@query('.resizable-img')
|
||||
accessor resizeImg!: HTMLElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-page-image': ImageBlockPageComponent;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { getImageSelectionsCommand } from '@blocksuite/affine-shared/commands';
|
||||
|
||||
import type { insertImagesCommand } from './commands/insert-images.js';
|
||||
|
||||
export function effects() {
|
||||
// TODO(@L-Sun): move other effects to this file
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface CommandContext {
|
||||
insertedImageIds?: Promise<string[]>;
|
||||
}
|
||||
|
||||
interface Commands {
|
||||
getImageSelections: typeof getImageSelectionsCommand;
|
||||
/**
|
||||
* open file dialog to insert images before or after the current block selection
|
||||
* @param removeEmptyLine remove the current block if it is empty
|
||||
* @param place where to insert the images
|
||||
* @returns a promise that resolves to the inserted image ids
|
||||
*/
|
||||
insertImages: typeof insertImagesCommand;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import { Peekable } from '@blocksuite/affine-components/peek';
|
||||
import type { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
import type { ImageBlockFallbackCard } from './components/image-block-fallback.js';
|
||||
import type { ImageBlockPageComponent } from './components/page-image-block.js';
|
||||
import type { ImageBlockService } from './image-service.js';
|
||||
import {
|
||||
copyImageBlob,
|
||||
downloadImageBlob,
|
||||
fetchImageBlob,
|
||||
turnImageIntoCardView,
|
||||
} from './utils.js';
|
||||
|
||||
@Peekable({
|
||||
enableOn: () => !IS_MOBILE,
|
||||
})
|
||||
export class ImageBlockComponent extends CaptionedBlockComponent<
|
||||
ImageBlockModel,
|
||||
ImageBlockService
|
||||
> {
|
||||
convertToCardView = () => {
|
||||
turnImageIntoCardView(this).catch(console.error);
|
||||
};
|
||||
|
||||
copy = () => {
|
||||
copyImageBlob(this).catch(console.error);
|
||||
};
|
||||
|
||||
download = () => {
|
||||
downloadImageBlob(this).catch(console.error);
|
||||
};
|
||||
|
||||
refreshData = () => {
|
||||
this.retryCount = 0;
|
||||
fetchImageBlob(this).catch(console.error);
|
||||
};
|
||||
|
||||
get resizableImg() {
|
||||
return this.pageImage?.resizeImg;
|
||||
}
|
||||
|
||||
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('block', {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.refreshData();
|
||||
this.contentEditable = 'false';
|
||||
this._disposables.add(
|
||||
this.model.propsUpdated.on(({ key }) => {
|
||||
if (key === 'sourceId') {
|
||||
this.refreshData();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
if (this.blobUrl) {
|
||||
URL.revokeObjectURL(this.blobUrl);
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
// lazy bindings
|
||||
this.disposables.addFromEvent(this, 'click', this._handleClick);
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const containerStyleMap = styleMap({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="affine-image-container" style=${containerStyleMap}>
|
||||
${when(
|
||||
this.loading || this.error,
|
||||
() =>
|
||||
html`<affine-image-fallback-card
|
||||
.error=${this.error}
|
||||
.loading=${this.loading}
|
||||
.mode=${'page'}
|
||||
></affine-image-fallback-card>`,
|
||||
() => html`<affine-page-image .block=${this}></affine-page-image>`
|
||||
)}
|
||||
</div>
|
||||
|
||||
${Object.values(this.widgets)}
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
this.fallbackCard?.requestUpdate();
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor blob: Blob | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor blobUrl: string | undefined = undefined;
|
||||
|
||||
override accessor blockContainerStyles = { margin: '18px 0' };
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor downloading = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor error = false;
|
||||
|
||||
@query('affine-image-fallback-card')
|
||||
accessor fallbackCard: ImageBlockFallbackCard | null = null;
|
||||
|
||||
@state()
|
||||
accessor lastSourceId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor loading = false;
|
||||
|
||||
@query('affine-page-image')
|
||||
private accessor pageImage: ImageBlockPageComponent | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor retryCount = 0;
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-image': ImageBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption';
|
||||
import { Peekable } from '@blocksuite/affine-components/peek';
|
||||
import type { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import { GfxBlockComponent } from '@blocksuite/block-std';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
import type { ImageBlockFallbackCard } from './components/image-block-fallback.js';
|
||||
import type { ImageBlockService } from './image-service.js';
|
||||
import {
|
||||
copyImageBlob,
|
||||
downloadImageBlob,
|
||||
fetchImageBlob,
|
||||
resetImageSize,
|
||||
turnImageIntoCardView,
|
||||
} from './utils.js';
|
||||
|
||||
@Peekable()
|
||||
export class ImageEdgelessBlockComponent extends GfxBlockComponent<
|
||||
ImageBlockModel,
|
||||
ImageBlockService
|
||||
> {
|
||||
static override styles = css`
|
||||
affine-edgeless-image .resizable-img,
|
||||
affine-edgeless-image .resizable-img img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
convertToCardView = () => {
|
||||
turnImageIntoCardView(this).catch(console.error);
|
||||
};
|
||||
|
||||
copy = () => {
|
||||
copyImageBlob(this).catch(console.error);
|
||||
};
|
||||
|
||||
download = () => {
|
||||
downloadImageBlob(this).catch(console.error);
|
||||
};
|
||||
|
||||
refreshData = () => {
|
||||
this.retryCount = 0;
|
||||
fetchImageBlob(this)
|
||||
.then(() => {
|
||||
const { width, height } = this.model;
|
||||
if (!width || !height) {
|
||||
return resetImageSize(this);
|
||||
}
|
||||
|
||||
return;
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
private _handleError(error: Error) {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: error }));
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.refreshData();
|
||||
this.contentEditable = 'false';
|
||||
this.disposables.add(
|
||||
this.model.propsUpdated.on(({ key }) => {
|
||||
if (key === 'sourceId') {
|
||||
this.refreshData();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
if (this.blobUrl) {
|
||||
URL.revokeObjectURL(this.blobUrl);
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override renderGfxBlock() {
|
||||
const rotate = this.model.rotate ?? 0;
|
||||
const containerStyleMap = styleMap({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
transformOrigin: 'center',
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="affine-image-container" style=${containerStyleMap}>
|
||||
${when(
|
||||
this.loading || this.error || !this.blobUrl,
|
||||
() =>
|
||||
html`<affine-image-fallback-card
|
||||
.error=${this.error}
|
||||
.loading=${this.loading}
|
||||
.mode=${'page'}
|
||||
></affine-image-fallback-card>`,
|
||||
() =>
|
||||
html`<div class="resizable-img">
|
||||
<img
|
||||
class="drag-target"
|
||||
src=${this.blobUrl ?? ''}
|
||||
draggable="false"
|
||||
@error=${this._handleError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>`
|
||||
)}
|
||||
<affine-block-selection .block=${this}></affine-block-selection>
|
||||
</div>
|
||||
<block-caption-editor></block-caption-editor>
|
||||
|
||||
${Object.values(this.widgets)}
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
this.fallbackCard?.requestUpdate();
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor blob: Blob | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor blobUrl: string | undefined = undefined;
|
||||
|
||||
@query('block-caption-editor')
|
||||
accessor captionEditor!: BlockCaptionEditor | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor downloading = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor error = false;
|
||||
|
||||
@query('affine-image-fallback-card')
|
||||
accessor fallbackCard: ImageBlockFallbackCard | null = null;
|
||||
|
||||
@state()
|
||||
accessor lastSourceId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor loading = false;
|
||||
|
||||
@query('.resizable-img')
|
||||
accessor resizableImg!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor retryCount = 0;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-edgeless-image': ImageEdgelessBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getClosestBlockComponentByElement,
|
||||
getModelByElement,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockComponent, PointerEventState } from '@blocksuite/block-std';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../root-block/index.js';
|
||||
import { getClosestRootBlockComponent } from '../root-block/utils/query.js';
|
||||
|
||||
export class ImageResizeManager {
|
||||
private _activeComponent: BlockComponent | null = null;
|
||||
|
||||
private _dragMoveTarget = 'right';
|
||||
|
||||
private _imageCenterX = 0;
|
||||
|
||||
private _imageContainer: HTMLElement | null = null;
|
||||
|
||||
private _zoom = 1;
|
||||
|
||||
onEnd() {
|
||||
assertExists(this._activeComponent);
|
||||
assertExists(this._imageContainer);
|
||||
|
||||
const dragModel = getModelByElement(this._activeComponent);
|
||||
dragModel?.page.captureSync();
|
||||
const { width, height } = this._imageContainer.getBoundingClientRect();
|
||||
dragModel?.page.updateBlock(dragModel, {
|
||||
width: width / this._zoom,
|
||||
height: height / this._zoom,
|
||||
});
|
||||
}
|
||||
|
||||
onMove(e: PointerEventState) {
|
||||
assertExists(this._activeComponent);
|
||||
const activeComponent = this._activeComponent;
|
||||
const activeImgContainer = this._imageContainer;
|
||||
assertExists(activeImgContainer);
|
||||
const activeImg = activeComponent.querySelector('img');
|
||||
assertExists(activeImg);
|
||||
|
||||
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.service.std.get(DocModeProvider).getEditorMode() ===
|
||||
'edgeless'
|
||||
) {
|
||||
this._zoom = (
|
||||
rootComponent as EdgelessRootBlockComponent
|
||||
).service.viewport.zoom;
|
||||
} else {
|
||||
this._zoom = 1;
|
||||
}
|
||||
|
||||
this._imageContainer = eventTarget.closest('.resizable-img');
|
||||
assertExists(this._imageContainer);
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { FileDropConfigExtension } from '@blocksuite/affine-components/drag-indicator';
|
||||
import { ImageBlockSchema, MAX_IMAGE_WIDTH } from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
isInsideEdgelessEditor,
|
||||
matchFlavours,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { BlockService } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
|
||||
import { setImageProxyMiddlewareURL } from '../_common/transformers/middlewares.js';
|
||||
import { addImages } from '../root-block/edgeless/utils/common.js';
|
||||
import { addSiblingImageBlock } from './utils.js';
|
||||
|
||||
// bytes.parse('2GB')
|
||||
const maxFileSize = 2147483648;
|
||||
|
||||
export class ImageBlockService extends BlockService {
|
||||
static override readonly flavour = ImageBlockSchema.model.flavour;
|
||||
|
||||
static setImageProxyURL = setImageProxyMiddlewareURL;
|
||||
|
||||
maxFileSize = maxFileSize;
|
||||
}
|
||||
|
||||
export const ImageDropOption = FileDropConfigExtension({
|
||||
flavour: ImageBlockSchema.model.flavour,
|
||||
onDrop: ({ files, targetModel, place, point, std }) => {
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!imageFiles.length) return false;
|
||||
|
||||
if (targetModel && !matchFlavours(targetModel, ['affine:surface'])) {
|
||||
addSiblingImageBlock(
|
||||
std.host,
|
||||
imageFiles,
|
||||
// TODO: use max file size from service
|
||||
maxFileSize,
|
||||
targetModel,
|
||||
place
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInsideEdgelessEditor(std.host)) {
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
point = gfx.viewport.toViewCoordFromClientCoord(point);
|
||||
addImages(std, files, { point, maxWidth: MAX_IMAGE_WIDTH }).catch(
|
||||
console.error
|
||||
);
|
||||
|
||||
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:drop',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'image',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ImageSelectionExtension } from '@blocksuite/affine-shared/selection';
|
||||
import {
|
||||
BlockViewExtension,
|
||||
CommandExtension,
|
||||
type ExtensionType,
|
||||
FlavourExtension,
|
||||
WidgetViewMapExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { ImageBlockAdapterExtensions } from './adapters/extension.js';
|
||||
import { commands } from './commands/index.js';
|
||||
import { ImageBlockService, ImageDropOption } from './image-service.js';
|
||||
|
||||
export const ImageBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:image'),
|
||||
ImageBlockService,
|
||||
CommandExtension(commands),
|
||||
BlockViewExtension('affine:image', model => {
|
||||
const parent = model.doc.getParent(model.id);
|
||||
|
||||
if (parent?.flavour === 'affine:surface') {
|
||||
return literal`affine-edgeless-image`;
|
||||
}
|
||||
|
||||
return literal`affine-image`;
|
||||
}),
|
||||
WidgetViewMapExtension('affine:image', {
|
||||
imageToolbar: literal`affine-image-toolbar-widget`,
|
||||
}),
|
||||
ImageDropOption,
|
||||
ImageSelectionExtension,
|
||||
ImageBlockAdapterExtensions,
|
||||
].flat();
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from './adapters/markdown.js';
|
||||
export * from './image-block.js';
|
||||
export * from './image-edgeless-block.js';
|
||||
export * from './image-service.js';
|
||||
export { uploadBlobForImage } from './utils.js';
|
||||
export { ImageSelection } from '@blocksuite/affine-shared/selection';
|
||||
@@ -1,72 +0,0 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
export const LoadingIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style xmlns="http://www.w3.org/2000/svg">
|
||||
.spinner {
|
||||
transform-origin: center;
|
||||
animation: spinner_animate 0.75s infinite linear;
|
||||
}
|
||||
@keyframes spinner_animate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
d="M14.6666 7.99992C14.6666 11.6818 11.6818 14.6666 7.99992 14.6666C4.31802 14.6666 1.33325 11.6818 1.33325 7.99992C1.33325 4.31802 4.31802 1.33325 7.99992 1.33325C11.6818 1.33325 14.6666 4.31802 14.6666 7.99992ZM3.30003 7.99992C3.30003 10.5956 5.40424 12.6998 7.99992 12.6998C10.5956 12.6998 12.6998 10.5956 12.6998 7.99992C12.6998 5.40424 10.5956 3.30003 7.99992 3.30003C5.40424 3.30003 3.30003 5.40424 3.30003 7.99992Z"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M13.6833 7.99992C14.2263 7.99992 14.674 7.55732 14.5942 7.02014C14.5142 6.48171 14.3684 5.95388 14.1591 5.4487C13.8241 4.63986 13.333 3.90493 12.714 3.28587C12.0949 2.66682 11.36 2.17575 10.5511 1.84072C10.046 1.63147 9.51812 1.48564 8.9797 1.40564C8.44251 1.32583 7.99992 1.77351 7.99992 2.31659C7.99992 2.85967 8.44486 3.28962 8.9761 3.40241C9.25681 3.46201 9.53214 3.54734 9.79853 3.65768C10.3688 3.89388 10.8869 4.24008 11.3233 4.67652C11.7598 5.11295 12.106 5.63108 12.3422 6.20131C12.4525 6.4677 12.5378 6.74303 12.5974 7.02374C12.7102 7.55498 13.1402 7.99992 13.6833 7.99992Z"
|
||||
fill="#1E96EB"
|
||||
class="spinner"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const ImageIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4 2.16667C2.98748 2.16667 2.16667 2.98748 2.16667 4V10.6667V12C2.16667 13.0125 2.98748 13.8333 4 13.8333H12C13.0125 13.8333 13.8333 13.0125 13.8333 12V9.33333V4C13.8333 2.98748 13.0125 2.16667 12 2.16667H4ZM3.16667 12V10.8738L6.07741 7.96303C6.40285 7.63759 6.93048 7.63759 7.25592 7.96303L8.97978 9.68689L10.3131 11.0202C10.5084 11.2155 10.825 11.2155 11.0202 11.0202C11.2155 10.825 11.2155 10.5084 11.0202 10.3131L10.0404 9.33333L10.7441 8.6297C11.0695 8.30426 11.5972 8.30426 11.9226 8.6297L12.8333 9.54044V12C12.8333 12.4602 12.4602 12.8333 12 12.8333H4C3.53976 12.8333 3.16667 12.4602 3.16667 12ZM7.96303 7.25592L9.33333 8.62623L10.037 7.92259C10.7529 7.20663 11.9137 7.20663 12.6297 7.92259L12.8333 8.12623V4C12.8333 3.53976 12.4602 3.16667 12 3.16667H4C3.53976 3.16667 3.16667 3.53976 3.16667 4V9.45956L5.3703 7.25592C6.08626 6.53996 7.24707 6.53996 7.96303 7.25592ZM9.33333 6C9.70152 6 10 5.70152 10 5.33333C10 4.96514 9.70152 4.66667 9.33333 4.66667C8.96514 4.66667 8.66667 4.96514 8.66667 5.33333C8.66667 5.70152 8.96514 6 9.33333 6Z"
|
||||
fill="#77757D"
|
||||
/>
|
||||
</svg> `;
|
||||
|
||||
export const FailedImageIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.1665 4.00008C2.1665 2.98756 2.98732 2.16675 3.99984 2.16675H11.9998C13.0124 2.16675 13.8332 2.98756 13.8332 4.00008V7.33341C13.8332 7.60956 13.6093 7.83341 13.3332 7.83341C13.057 7.83341 12.8332 7.60956 12.8332 7.33341V4.00008C12.8332 3.53984 12.4601 3.16675 11.9998 3.16675H3.99984C3.5396 3.16675 3.1665 3.53984 3.1665 4.00008V9.45964L5.37014 7.256C6.0861 6.54004 7.2469 6.54004 7.96287 7.256L8.35339 7.64653C8.54865 7.84179 8.54865 8.15837 8.35339 8.35363C8.15813 8.5489 7.84155 8.5489 7.64628 8.35363L7.25576 7.96311C6.93032 7.63767 6.40268 7.63767 6.07725 7.96311L3.1665 10.8739V12.0001C3.1665 12.4603 3.5396 12.8334 3.99984 12.8334H7.33317C7.60931 12.8334 7.83317 13.0573 7.83317 13.3334C7.83317 13.6096 7.60931 13.8334 7.33317 13.8334H3.99984C2.98732 13.8334 2.1665 13.0126 2.1665 12.0001V4.00008Z"
|
||||
fill="#77757D"
|
||||
fill-opacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M9.99984 5.33341C9.99984 5.7016 9.70136 6.00008 9.33317 6.00008C8.96498 6.00008 8.6665 5.7016 8.6665 5.33341C8.6665 4.96522 8.96498 4.66675 9.33317 4.66675C9.70136 4.66675 9.99984 4.96522 9.99984 5.33341Z"
|
||||
fill="#77757D"
|
||||
fill-opacity="0.6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.97962 8.97986C9.17488 8.7846 9.49146 8.7846 9.68672 8.97986L11.3332 10.6263L12.9796 8.97986C13.1749 8.7846 13.4915 8.7846 13.6867 8.97986C13.882 9.17512 13.882 9.49171 13.6867 9.68697L12.0403 11.3334L13.6867 12.9799C13.882 13.1751 13.882 13.4917 13.6867 13.687C13.4915 13.8822 13.1749 13.8822 12.9796 13.687L11.3332 12.0405L9.68672 13.687C9.49146 13.8822 9.17488 13.8822 8.97962 13.687C8.78435 13.4917 8.78435 13.1751 8.97962 12.9799L10.6261 11.3334L8.97962 9.68697C8.78435 9.49171 8.78435 9.17512 8.97962 8.97986Z"
|
||||
fill="#77757D"
|
||||
fill-opacity="0.6"
|
||||
/>
|
||||
</svg> `;
|
||||
@@ -1,400 +0,0 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type {
|
||||
AttachmentBlockProps,
|
||||
ImageBlockModel,
|
||||
ImageBlockProps,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
downloadBlob,
|
||||
humanFileSize,
|
||||
transformModel,
|
||||
withTempBlobData,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { readImageSize } from '../root-block/edgeless/components/utils.js';
|
||||
import type { ImageBlockComponent } from './image-block.js';
|
||||
import type { ImageEdgelessBlockComponent } from './image-edgeless-block.js';
|
||||
|
||||
const MAX_RETRY_COUNT = 3;
|
||||
const DEFAULT_ATTACHMENT_NAME = 'affine-attachment';
|
||||
|
||||
const imageUploads = new Set<string>();
|
||||
export function setImageUploading(blockId: string) {
|
||||
imageUploads.add(blockId);
|
||||
}
|
||||
export function setImageUploaded(blockId: string) {
|
||||
imageUploads.delete(blockId);
|
||||
}
|
||||
export function isImageUploading(blockId: string) {
|
||||
return imageUploads.has(blockId);
|
||||
}
|
||||
|
||||
export async function uploadBlobForImage(
|
||||
editorHost: EditorHost,
|
||||
blockId: string,
|
||||
blob: Blob
|
||||
): Promise<void> {
|
||||
if (isImageUploading(blockId)) {
|
||||
console.error('The image is already uploading!');
|
||||
return;
|
||||
}
|
||||
setImageUploading(blockId);
|
||||
const doc = editorHost.doc;
|
||||
let sourceId: string | undefined;
|
||||
|
||||
try {
|
||||
sourceId = await doc.blobSync.set(blob);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
toast(
|
||||
editorHost,
|
||||
`Failed to upload image! ${error.message || error.toString()}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setImageUploaded(blockId);
|
||||
|
||||
const imageModel = doc.getBlockById(blockId) as ImageBlockModel | null;
|
||||
|
||||
doc.withoutTransact(() => {
|
||||
if (!imageModel) {
|
||||
return;
|
||||
}
|
||||
doc.updateBlock(imageModel, {
|
||||
sourceId,
|
||||
} satisfies Partial<ImageBlockProps>);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getImageBlob(model: ImageBlockModel) {
|
||||
const sourceId = model.sourceId;
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = model.doc;
|
||||
const 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);
|
||||
if (!fileType?.mime.startsWith('image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: fileType.mime });
|
||||
}
|
||||
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function fetchImageBlob(
|
||||
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
||||
) {
|
||||
try {
|
||||
if (block.model.sourceId !== block.lastSourceId || !block.blobUrl) {
|
||||
block.loading = true;
|
||||
block.error = false;
|
||||
block.blob = undefined;
|
||||
|
||||
if (block.blobUrl) {
|
||||
URL.revokeObjectURL(block.blobUrl);
|
||||
block.blobUrl = undefined;
|
||||
}
|
||||
} else if (block.blobUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { model } = block;
|
||||
const { id, sourceId, doc } = model;
|
||||
|
||||
if (isImageUploading(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await doc.blobSync.get(sourceId);
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
block.loading = false;
|
||||
block.blob = blob;
|
||||
block.blobUrl = URL.createObjectURL(blob);
|
||||
block.lastSourceId = sourceId;
|
||||
} catch (error) {
|
||||
block.retryCount++;
|
||||
console.warn(`${error}, retrying`, block.retryCount);
|
||||
|
||||
if (block.retryCount < MAX_RETRY_COUNT) {
|
||||
setTimeout(() => {
|
||||
fetchImageBlob(block).catch(console.error);
|
||||
// 1s, 2s, 3s
|
||||
}, 1000 * block.retryCount);
|
||||
} else {
|
||||
block.loading = false;
|
||||
block.error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadImageBlob(
|
||||
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
||||
) {
|
||||
const { host, downloading } = block;
|
||||
if (downloading) {
|
||||
toast(host, 'Download in progress...');
|
||||
return;
|
||||
}
|
||||
|
||||
block.downloading = true;
|
||||
|
||||
const blob = await getImageBlob(block.model);
|
||||
if (!blob) {
|
||||
toast(host, `Unable to download image!`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast(host, `Downloading image...`);
|
||||
|
||||
downloadBlob(blob, 'image');
|
||||
|
||||
block.downloading = false;
|
||||
}
|
||||
|
||||
export async function resetImageSize(
|
||||
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
||||
) {
|
||||
const { blob, model } = block;
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File([blob], 'image.png', { type: blob.type });
|
||||
const size = await readImageSize(file);
|
||||
block.doc.updateBlock(model, {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
}
|
||||
|
||||
function convertToString(blob: Blob): Promise<string | null> {
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', _ => resolve(reader.result as string));
|
||||
reader.addEventListener('error', () => resolve(null));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function convertToPng(blob: Blob): Promise<Blob | null> {
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', _ => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.width;
|
||||
c.height = img.height;
|
||||
const ctx = c.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
c.toBlob(resolve, 'image/png');
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = reader.result as string;
|
||||
});
|
||||
reader.addEventListener('error', () => resolve(null));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyImageBlob(
|
||||
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
||||
) {
|
||||
const { host, model } = block;
|
||||
let blob = await getImageBlob(model);
|
||||
if (!blob) {
|
||||
console.error('Failed to get image blob');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
if (window.apis?.clipboard?.copyAsImageFromString) {
|
||||
const dataURL = await convertToString(blob);
|
||||
if (!dataURL)
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
'Cant convert a blob to data URL.'
|
||||
);
|
||||
// @ts-expect-error FIXME: ts error
|
||||
await window.apis.clipboard?.copyAsImageFromString(dataURL);
|
||||
} else {
|
||||
// DOMException: Type image/jpeg not supported on write.
|
||||
if (blob.type !== 'image/png') {
|
||||
const pngBlob = await convertToPng(blob);
|
||||
if (!pngBlob) {
|
||||
console.error('Failed to convert blob to PNG');
|
||||
return;
|
||||
}
|
||||
blob = pngBlob;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldResizeImage(node: Node, target: EventTarget | null) {
|
||||
return !!(
|
||||
target &&
|
||||
target instanceof HTMLElement &&
|
||||
node.contains(target) &&
|
||||
target.classList.contains('resize')
|
||||
);
|
||||
}
|
||||
|
||||
export function addSiblingImageBlock(
|
||||
editorHost: EditorHost,
|
||||
files: File[],
|
||||
maxFileSize: number,
|
||||
targetModel: BlockModel,
|
||||
place: 'after' | 'before' = 'after'
|
||||
) {
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!imageFiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize);
|
||||
if (isSizeExceeded) {
|
||||
toast(
|
||||
editorHost,
|
||||
`You can only upload files less than ${humanFileSize(
|
||||
maxFileSize,
|
||||
true,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageBlockProps: Partial<ImageBlockProps> &
|
||||
{
|
||||
flavour: 'affine:image';
|
||||
}[] = imageFiles.map(file => ({
|
||||
flavour: 'affine:image',
|
||||
size: file.size,
|
||||
}));
|
||||
|
||||
const doc = editorHost.doc;
|
||||
const blockIds = doc.addSiblingBlocks(targetModel, imageBlockProps, place);
|
||||
blockIds.forEach(
|
||||
(blockId, index) =>
|
||||
void uploadBlobForImage(editorHost, blockId, imageFiles[index])
|
||||
);
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
export function addImageBlocks(
|
||||
editorHost: EditorHost,
|
||||
files: File[],
|
||||
maxFileSize: number,
|
||||
parent?: BlockModel | string | null,
|
||||
parentIndex?: number
|
||||
) {
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!imageFiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize);
|
||||
if (isSizeExceeded) {
|
||||
toast(
|
||||
editorHost,
|
||||
`You can only upload files less than ${humanFileSize(
|
||||
maxFileSize,
|
||||
true,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = editorHost.doc;
|
||||
const blockIds = imageFiles.map(file =>
|
||||
doc.addBlock('affine:image', { size: file.size }, parent, parentIndex)
|
||||
);
|
||||
blockIds.forEach(
|
||||
(blockId, index) =>
|
||||
void uploadBlobForImage(editorHost, blockId, imageFiles[index])
|
||||
);
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the image block into a attachment block.
|
||||
*/
|
||||
export async function turnImageIntoCardView(
|
||||
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
||||
) {
|
||||
const doc = block.doc;
|
||||
if (!doc.schema.flavourSchemaMap.has('affine:attachment')) {
|
||||
console.error('The attachment flavour is not supported!');
|
||||
return;
|
||||
}
|
||||
|
||||
const model = block.model;
|
||||
const sourceId = model.sourceId;
|
||||
const blob = await getImageBlob(model);
|
||||
if (!sourceId || !blob) {
|
||||
console.error('Image data not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const { saveImageData, getAttachmentData } = withTempBlobData();
|
||||
saveImageData(sourceId, { width: model.width, height: model.height });
|
||||
const attachmentConvertData = getAttachmentData(sourceId);
|
||||
const attachmentProp: Partial<AttachmentBlockProps> = {
|
||||
sourceId,
|
||||
name: DEFAULT_ATTACHMENT_NAME,
|
||||
size: blob.size,
|
||||
type: blob.type,
|
||||
caption: model.caption,
|
||||
...attachmentConvertData,
|
||||
};
|
||||
transformModel(model, 'affine:attachment', attachmentProp);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ export * from './database-block/index.js';
|
||||
export * from './divider-block/index.js';
|
||||
export * from './edgeless-text-block/index.js';
|
||||
export * from './frame-block/index.js';
|
||||
export * from './image-block/index.js';
|
||||
export * from './latex-block/index.js';
|
||||
export * from './note-block/index.js';
|
||||
export { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js';
|
||||
@@ -51,6 +50,7 @@ export * from './surface-ref-block/index.js';
|
||||
export * from '@blocksuite/affine-block-attachment';
|
||||
export * from '@blocksuite/affine-block-bookmark';
|
||||
export * from '@blocksuite/affine-block-embed';
|
||||
export * from '@blocksuite/affine-block-image';
|
||||
export * from '@blocksuite/affine-block-list';
|
||||
export * from '@blocksuite/affine-block-paragraph';
|
||||
export * from '@blocksuite/affine-block-surface';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { addAttachments } from '@blocksuite/affine-block-attachment';
|
||||
import { addImages } from '@blocksuite/affine-block-image';
|
||||
import {
|
||||
CanvasElementType,
|
||||
SurfaceGroupLikeModel,
|
||||
@@ -76,7 +77,6 @@ import {
|
||||
getSortedCloneElements,
|
||||
serializeElement,
|
||||
} from '../utils/clone-utils.js';
|
||||
import { addImages } from '../utils/common.js';
|
||||
import { deleteElements } from '../utils/crud.js';
|
||||
import {
|
||||
isAttachmentBlock,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { addAttachments } from '@blocksuite/affine-block-attachment';
|
||||
import { addImages, LoadedImageIcon } from '@blocksuite/affine-block-image';
|
||||
import { AttachmentIcon, LinkIcon } from '@blocksuite/affine-components/icons';
|
||||
import { MAX_IMAGE_WIDTH } from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
@@ -13,9 +14,7 @@ import {
|
||||
type NoteChildrenFlavour,
|
||||
openFileOrFiles,
|
||||
} from '../../../../../_common/utils/index.js';
|
||||
import { ImageIcon } from '../../../../../image-block/styles.js';
|
||||
import type { NoteToolOption } from '../../../gfx-tool/note-tool.js';
|
||||
import { addImages } from '../../../utils/common.js';
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import { NOTE_MENU_ITEMS } from './note-menu-config.js';
|
||||
@@ -118,7 +117,7 @@ export class EdgelessNoteMenu extends EdgelessToolbarToolMixin(LitElement) {
|
||||
@click=${this._addImages}
|
||||
.disabled=${this._imageLoading}
|
||||
>
|
||||
${ImageIcon}
|
||||
${LoadedImageIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { CommonUtils } from '@blocksuite/affine-block-surface';
|
||||
import type { CursorType, StandardCursor } from '@blocksuite/block-std/gfx';
|
||||
import type { IVec } from '@blocksuite/global/utils';
|
||||
import { assertExists, Bound, Vec } from '@blocksuite/global/utils';
|
||||
import { assertExists, Vec } from '@blocksuite/global/utils';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
import {
|
||||
SURFACE_IMAGE_CARD_HEIGHT,
|
||||
SURFACE_IMAGE_CARD_WIDTH,
|
||||
} from '../../../image-block/components/image-block-fallback.js';
|
||||
|
||||
// "<svg width='32' height='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'><g><path fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/><path d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/></g></svg>";
|
||||
export function generateCursorUrl(
|
||||
angle = 0,
|
||||
@@ -79,27 +74,6 @@ export function getTooltipWithShortcut(
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function readImageSize(file: File) {
|
||||
return new Promise<{ width: number; height: number }>(resolve => {
|
||||
const size = { width: 0, height: 0 };
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
size.width = img.width;
|
||||
size.height = img.height;
|
||||
URL.revokeObjectURL(img.src);
|
||||
resolve(size);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
resolve(size);
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
const RESIZE_CURSORS: CursorType[] = [
|
||||
'ew-resize',
|
||||
'nwse-resize',
|
||||
@@ -245,14 +219,3 @@ export function launchIntoFullscreen(element: Element) {
|
||||
element.msRequestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,136 +1,25 @@
|
||||
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
type ImageBlockProps,
|
||||
NOTE_MIN_HEIGHT,
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { NoteChildrenFlavour } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
handleNativeRangeAtPoint,
|
||||
humanFileSize,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
type IPoint,
|
||||
type IVec,
|
||||
Point,
|
||||
type Point,
|
||||
serializeXYWH,
|
||||
Vec,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import { calcBoundByOrigin, readImageSize } from '../components/utils.js';
|
||||
import { DEFAULT_NOTE_OFFSET_X, DEFAULT_NOTE_OFFSET_Y } from './consts.js';
|
||||
import { addBlock } from './crud.js';
|
||||
|
||||
export async function addImages(
|
||||
std: BlockStdScope,
|
||||
files: File[],
|
||||
options: {
|
||||
point?: IVec;
|
||||
maxWidth?: number;
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const imageFiles = [...files].filter(file => file.type.startsWith('image/'));
|
||||
if (!imageFiles.length) return [];
|
||||
|
||||
const imageService = std.getService('affine:image');
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
|
||||
if (!imageService) {
|
||||
console.error('Image service not found');
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxFileSize = imageService.maxFileSize;
|
||||
const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize);
|
||||
if (isSizeExceeded) {
|
||||
toast(
|
||||
std.host,
|
||||
`You can only upload files less than ${humanFileSize(
|
||||
maxFileSize,
|
||||
true,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const { point, maxWidth } = options;
|
||||
let { x, y } = gfx.viewport.center;
|
||||
if (point) [x, y] = gfx.viewport.toModelCoord(...point);
|
||||
|
||||
const dropInfos: { point: Point; blockId: string }[] = [];
|
||||
const IMAGE_STACK_GAP = 32;
|
||||
const isMultipleFiles = imageFiles.length > 1;
|
||||
const inTopLeft = isMultipleFiles ? true : false;
|
||||
|
||||
// create image cards without image data
|
||||
imageFiles.forEach((file, index) => {
|
||||
const point = new Point(
|
||||
x + index * IMAGE_STACK_GAP,
|
||||
y + index * IMAGE_STACK_GAP
|
||||
);
|
||||
const center = Vec.toVec(point);
|
||||
const bound = calcBoundByOrigin(center, inTopLeft);
|
||||
const blockId = std.doc.addBlock(
|
||||
'affine:image',
|
||||
{
|
||||
size: file.size,
|
||||
xywh: bound.serialize(),
|
||||
index: gfx.layer.generateIndex(),
|
||||
},
|
||||
gfx.surface
|
||||
);
|
||||
dropInfos.push({ point, blockId });
|
||||
});
|
||||
|
||||
// upload image data and update the image model
|
||||
const uploadPromises = imageFiles.map(async (file, index) => {
|
||||
const { point, blockId } = dropInfos[index];
|
||||
|
||||
const sourceId = await std.doc.blobSync.set(file);
|
||||
const imageSize = await readImageSize(file);
|
||||
|
||||
const center = Vec.toVec(point);
|
||||
// If maxWidth is provided, limit the width of the image to maxWidth
|
||||
// Otherwise, use the original width
|
||||
const width = maxWidth
|
||||
? Math.min(imageSize.width, maxWidth)
|
||||
: imageSize.width;
|
||||
const height = maxWidth
|
||||
? (imageSize.height / imageSize.width) * width
|
||||
: imageSize.height;
|
||||
const bound = calcBoundByOrigin(center, inTopLeft, width, height);
|
||||
|
||||
std.doc.withoutTransact(() => {
|
||||
gfx.updateElement(blockId, {
|
||||
sourceId,
|
||||
...imageSize,
|
||||
width,
|
||||
height,
|
||||
xywh: bound.serialize(),
|
||||
} satisfies Partial<ImageBlockProps>);
|
||||
});
|
||||
});
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
const blockIds = dropInfos.map(info => info.blockId);
|
||||
gfx.selection.set({
|
||||
elements: blockIds,
|
||||
editing: false,
|
||||
});
|
||||
if (isMultipleFiles) {
|
||||
std.command.exec('autoResizeElements');
|
||||
}
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
export function addNoteAtPoint(
|
||||
std: BlockStdScope,
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
downloadImageBlob,
|
||||
type ImageBlockComponent,
|
||||
} from '@blocksuite/affine-block-image';
|
||||
import { CaptionIcon, DownloadIcon } from '@blocksuite/affine-components/icons';
|
||||
import type { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
|
||||
import { downloadImageBlob } from '../../../image-block/utils.js';
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export class EdgelessChangeImageButton extends WithDisposable(LitElement) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
EmbedLoomBlockComponent,
|
||||
EmbedYoutubeBlockComponent,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '../../../../_common/utils/render-linked-doc.js';
|
||||
import type { ImageBlockComponent } from '../../../../image-block/image-block.js';
|
||||
import { duplicate } from '../../../edgeless/utils/clipboard-utils.js';
|
||||
import { getSortedCloneElements } from '../../../edgeless/utils/clone-utils.js';
|
||||
import { moveConnectors } from '../../../edgeless/utils/connector.js';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import { MenuContext } from '@blocksuite/affine-components/toolbar';
|
||||
|
||||
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
|
||||
|
||||
export class ImageToolbarContext extends MenuContext {
|
||||
override close = () => {
|
||||
this.abortController.abort();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import { HoverController } from '@blocksuite/affine-components/hover';
|
||||
import type {
|
||||
AdvancedMenuItem,
|
||||
@@ -13,7 +14,6 @@ import { WidgetComponent } from '@blocksuite/block-std';
|
||||
import { limitShift, shift } from '@floating-ui/dom';
|
||||
import { html } from 'lit';
|
||||
|
||||
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
|
||||
import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js';
|
||||
import { ImageToolbarContext } from './context.js';
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import {
|
||||
getBlockProps,
|
||||
isInsidePageEditor,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
|
||||
|
||||
export function duplicate(
|
||||
block: ImageBlockComponent,
|
||||
abortController?: AbortController
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
{
|
||||
"path": "../affine/block-attachment"
|
||||
},
|
||||
{
|
||||
"path": "../affine/block-image"
|
||||
},
|
||||
{
|
||||
"path": "../affine/data-view"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user