feat: dnd image preview && edgeless dnd preview issue (#10177)

### Changed
- Add new image block to render dnd preview
- Fixed the bug that dragging uploaded image does not have width and height
- Fixed the bug that drag image block from page to edgeless does not have width and height
- Better edgeless dnd preview
This commit is contained in:
doouding
2025-02-14 16:02:03 +00:00
parent 631c8b8145
commit 8ece812017
11 changed files with 245 additions and 53 deletions
@@ -2,10 +2,20 @@ 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
);
}
@@ -43,7 +43,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
fetchImageBlob(this)
.then(() => {
const { width, height } = this.model;
if (!width || !height) {
if ((!width || !height) && this.blob) {
return resetImageSize(this);
}
@@ -0,0 +1,25 @@
import { toGfxBlockComponent } from '@blocksuite/block-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 var(--affine-background-tertiary-color);
}
`;
override renderGfxBlock(): unknown {
return super.renderGfxBlock();
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-placeholder-preview-image': ImageEdgelessPlaceholderBlockComponent;
}
}
@@ -0,0 +1,58 @@
import type { ImageBlockModel } from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { BlockComponent } from '@blocksuite/block-std';
import { ImageIcon } from '@blocksuite/icons/lit';
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;
}
}
+35 -11
View File
@@ -68,15 +68,30 @@ export async function uploadBlobForImage(
setImageUploaded(blockId);
const imageModel = doc.getBlockById(blockId) as ImageBlockModel | null;
doc.withoutTransact(() => {
if (!imageModel) {
return;
}
doc.updateBlock(imageModel, {
if (sourceId && imageModel) {
const props: Partial<ImageBlockProps> = {
sourceId,
} satisfies Partial<ImageBlockProps>);
});
// Assign a default size to make sure the image can be displayed correctly.
width: 100,
height: 100,
};
const blob = await doc.blobSync.get(sourceId);
if (blob) {
try {
const size = await readImageSize(blob);
props.width = size.width;
props.height = size.height;
} catch {
// Ignore the error
console.warn('Failed to read image size');
}
}
doc.withoutTransact(() => {
doc.updateBlock(imageModel, props);
});
}
}
}
@@ -198,10 +213,19 @@ export async function resetImageSize(
const file = new File([blob], 'image.png', { type: blob.type });
const size = await readImageSize(file);
block.doc.updateBlock(model, {
const bound = model.elementBound;
const props: Partial<ImageBlockProps> = {
width: size.width,
height: size.height,
});
};
if (!bound.w || !bound.h) {
bound.w = size.width;
bound.h = size.height;
props.xywh = bound.serialize();
}
block.doc.updateBlock(model, props);
}
function convertToPng(blob: Blob): Promise<Blob | null> {
@@ -401,7 +425,7 @@ export async function turnImageIntoCardView(
transformModel(model, 'affine:attachment', attachmentProp);
}
export function readImageSize(file: File) {
export function readImageSize(file: File | Blob) {
return new Promise<{ width: number; height: number }>(resolve => {
const size = { width: 0, height: 0 };
const img = new Image();
@@ -1,24 +1,37 @@
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import { RootBlockModel } from '@blocksuite/affine-model';
import {
DocModeExtension,
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
} from '@blocksuite/affine-shared/services';
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import { BlockStdScope, LifeCycleWatcher } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { matchModels, SpecProvider } from '@blocksuite/affine-shared/utils';
import {
type BlockViewType,
type Query,
type SliceSnapshot,
type BlockComponent,
BlockStdScope,
BlockViewIdentifier,
LifeCycleWatcher,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import type {
BlockModel,
BlockViewType,
ExtensionType,
Query,
SliceSnapshot,
} from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { literal } from 'lit/static-html.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import { getSnapshotRect } from '../utils.js';
export class PreviewHelper {
private readonly _calculateQuery = (selectedIds: string[]): Query => {
private readonly _calculateQuery = (
selectedIds: string[],
mode: 'page' | 'edgeless'
): Query => {
const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map(
id => ({
id,
@@ -39,11 +52,28 @@ export class PreviewHelper {
// The children of the selected blocks should be rendered as Display
const addChildren = (id: string) => {
const children = this.widget.doc.getBlock(id)?.model.children ?? [];
children.forEach(child => {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
});
const model = this.widget.doc.getBlock(id)?.model;
if (!model) {
return;
}
const children = model.children ?? [];
if (
mode === 'edgeless' &&
matchModels(model, [RootBlockModel, SurfaceBlockModel])
) {
children.forEach(child => {
if (selectedIds.includes(child.id)) {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
}
});
} else {
children.forEach(child => {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
});
}
};
selectedIds.forEach(addChildren);
@@ -61,23 +91,42 @@ export class PreviewHelper {
const widget = this.widget;
const std = widget.std;
const sourceGfx = std.get(GfxControllerIdentifier);
const isEdgeless = mode === 'edgeless';
blockIds = blockIds.slice();
if (isEdgeless) {
blockIds.push(sourceGfx.surface!.id, std.store.root!.id);
}
const docModeService = std.get(DocModeProvider);
const editorSetting = std.get(EditorSettingProvider).peek();
const query = this._calculateQuery(blockIds as string[]);
const query = this._calculateQuery(blockIds as string[], mode);
const store = widget.doc.doc.getStore({ query });
const previewSpec = SpecProvider.getInstance().getSpec(
mode === 'edgeless' ? 'edgeless:preview' : 'page:preview'
isEdgeless ? 'edgeless:preview' : 'page:preview'
);
const settingSignal = signal({ ...editorSetting });
const extensions = [
DocModeExtension(docModeService),
EditorSettingExtension(settingSignal),
{
setup(di) {
di.override(BlockViewIdentifier('affine:image'), () => {
return (model: BlockModel) => {
const parent = model.doc.getParent(model.id);
if (parent?.flavour === 'affine:surface') {
return literal`affine-edgeless-placeholder-preview-image`;
}
return literal`affine-placeholder-preview-image`;
};
});
},
} as ExtensionType,
];
if (mode === 'edgeless') {
if (!blockIds.includes(sourceGfx.surface!.id)) {
blockIds.push(sourceGfx.surface!.id);
}
if (isEdgeless) {
class PreviewViewportInitializer extends LifeCycleWatcher {
static override key = 'preview-viewport-initializer';
@@ -91,9 +140,11 @@ export class PreviewHelper {
if (payload.view.model.flavour === 'affine:page') {
const gfx = this.std.get(GfxControllerIdentifier);
queueMicrotask(() => {
gfx.viewport.setViewportByBound(rect);
});
(
payload.view as BlockComponent & { overrideBackground: string }
).overrideBackground = 'transparent';
gfx.viewport.setViewportByBound(rect);
}
});
}
@@ -116,11 +167,9 @@ export class PreviewHelper {
let width: number = 500;
let height;
let scale = 1;
if (mode === 'page') {
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
} else {
if (isEdgeless) {
const rect = getSnapshotRect(snapshot);
if (rect) {
width = rect.w;
@@ -128,9 +177,14 @@ export class PreviewHelper {
} else {
height = 500;
}
scale = sourceGfx.viewport.zoom;
} else {
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
}
return {
scale,
previewStd,
width,
height,
@@ -144,13 +198,14 @@ export class PreviewHelper {
mode: 'page' | 'edgeless';
}): void => {
const { blockIds, snapshot, container, mode } = options;
const { previewStd, width, height } = this.getPreviewStd(
const { previewStd, width, height, scale } = this.getPreviewStd(
blockIds,
snapshot,
mode
);
const previewTemplate = previewStd.render();
container.style.transform = `scale(${scale})`;
container.style.width = `${width}px`;
if (height) {
container.style.height = `${height}px`;
@@ -349,11 +349,12 @@ export class DragEventWatcher {
return selectedElements;
};
const toSnapshotRequiredBlocks = (models: string[]) => {
const toSnapshotRequiredBlocks = (selectedModels: string[]) => {
let surfaceAdded = false;
const blocks: BlockModel[] = [];
const blocksUnderSurface: BlockModel[] = [];
models.forEach(id => {
selectedModels.forEach(id => {
const model = this.gfx.getElementById(id);
if (!model) {
@@ -368,16 +369,19 @@ export class DragEventWatcher {
const parentModel = this.std.store.getParent(model);
if (matchModels(parentModel, [SurfaceBlockModel])) {
if (surfaceAdded) return;
surfaceAdded = true;
blocks.push(this.gfx.surface!);
blocksUnderSurface.push(model);
} else {
blocks.push(model);
}
}
});
return blocks;
if (surfaceAdded) {
// surface children are included, so no need to add the blocksUnderSurface
return blocks;
} else {
return blocks.concat(blocksUnderSurface);
}
};
const selectedElements = getElementsInContainer(
@@ -953,13 +957,19 @@ export class DragEventWatcher {
block.flavour === 'affine:attachment' ||
block.flavour.startsWith('affine:embed-')
) {
const style = (block.props.style ?? 'vertical') as EmbedCardStyle;
const style = 'vertical' as EmbedCardStyle;
block.props.style = style;
blockBound.w = EMBED_CARD_WIDTH[style];
blockBound.h = EMBED_CARD_HEIGHT[style];
}
if (block.flavour === 'affine:image') {
assertType<{ width: number; height: number }>(block.props);
blockBound.w = blockBound.w || block.props.width || 100;
blockBound.h = blockBound.h || block.props.height || 100;
}
if (ignoreOriginalPos) {
blockBound.x = modelX;
blockBound.y = modelY;
@@ -1013,8 +1023,10 @@ export class DragEventWatcher {
.then(slices => {
slices?.content.forEach((block, idx) => {
if (
block.flavour === 'affine:attachment' ||
block.flavour.startsWith('affine:embed-')
block.id === content[idx].id &&
(block.flavour === 'affine:image' ||
block.flavour === 'affine:attachment' ||
block.flavour.startsWith('affine:embed-'))
) {
store.updateBlock(block as BlockModel, {
xywh: content[idx].props.xywh,
@@ -27,7 +27,7 @@ export class EdgelessWatcher {
private readonly _handleEdgelessToolUpdated = (
newTool: GfxToolsFullOptionValue
) => {
// @ts-expect-error FIXME: resolve after gfx tool refactor
// @ts-expect-error GfxToolsFullOptionValue is extended in other packages
if (newTool.type === 'default') {
this.updateAnchorElement();
} else {
@@ -19,6 +19,7 @@ import type { GfxViewportElement } from '@blocksuite/block-std/gfx';
import { assertExists } from '@blocksuite/global/utils';
import { css, html } from 'lit';
import { query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockWidgetName } from '../types.js';
import type { EdgelessRootService } from './edgeless-root-service.js';
@@ -229,8 +230,12 @@ export class EdgelessRootPreviewBlockComponent
}
override renderBlock() {
const background = styleMap({
background: this.overrideBackground,
});
return html`
<div class="edgeless-background edgeless-container">
<div class="edgeless-background edgeless-container" style=${background}>
<gfx-viewport
.enableChildrenSchedule=${!this._disableScheduleUpdate}
.viewport=${this.service.viewport}
@@ -260,6 +265,9 @@ export class EdgelessRootPreviewBlockComponent
}
}
@state()
accessor overrideBackground: string | undefined = undefined;
@state()
accessor editorViewportSelector = '.affine-edgeless-viewport';
@@ -1,3 +1,4 @@
export { FramePreview } from './components/frame/frame-preview.js';
export * from './edgeless-root-block.js';
export { EdgelessRootPreviewBlockComponent } from './edgeless-root-preview-block.js';
export { EdgelessRootService } from './edgeless-root-service.js';
@@ -46,7 +46,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
}
`;
private readonly _hideOutsideBlock = requestThrottledConnectedFrame(() => {
private readonly _hideOutsideBlock = () => {
if (this.getModelsInViewport && this.host) {
const host = this.host;
const modelsInViewport = this.getModelsInViewport();
@@ -73,7 +73,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
this._lastVisibleModels = modelsInViewport;
}
}, this);
};
private _lastVisibleModels?: Set<GfxBlockElementModel>;
@@ -95,14 +95,13 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
const viewportUpdateCallback = () => {
this._refreshViewport();
this._hideOutsideBlock();
};
if (!this.enableChildrenSchedule) {
delete this.scheduleUpdateChildren;
}
viewportUpdateCallback();
this._hideOutsideBlock();
this.disposables.add(
this.viewport.viewportUpdated.on(() => viewportUpdateCallback())
);