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

View File

@@ -2,10 +2,20 @@ import { ImageBlockFallbackCard } from './components/image-block-fallback.js';
import { ImageBlockPageComponent } from './components/page-image-block.js'; import { ImageBlockPageComponent } from './components/page-image-block.js';
import { ImageBlockComponent } from './image-block.js'; import { ImageBlockComponent } from './image-block.js';
import { ImageEdgelessBlockComponent } from './image-edgeless-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() { export function effects() {
customElements.define('affine-image', ImageBlockComponent); customElements.define('affine-image', ImageBlockComponent);
customElements.define('affine-edgeless-image', ImageEdgelessBlockComponent); customElements.define('affine-edgeless-image', ImageEdgelessBlockComponent);
customElements.define('affine-page-image', ImageBlockPageComponent); customElements.define('affine-page-image', ImageBlockPageComponent);
customElements.define('affine-image-fallback-card', ImageBlockFallbackCard); customElements.define('affine-image-fallback-card', ImageBlockFallbackCard);
customElements.define(
'affine-placeholder-preview-image',
ImagePlaceholderBlockComponent
);
customElements.define(
'affine-edgeless-placeholder-preview-image',
ImageEdgelessPlaceholderBlockComponent
);
} }

View File

@@ -43,7 +43,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
fetchImageBlob(this) fetchImageBlob(this)
.then(() => { .then(() => {
const { width, height } = this.model; const { width, height } = this.model;
if (!width || !height) { if ((!width || !height) && this.blob) {
return resetImageSize(this); return resetImageSize(this);
} }

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -68,15 +68,30 @@ export async function uploadBlobForImage(
setImageUploaded(blockId); setImageUploaded(blockId);
const imageModel = doc.getBlockById(blockId) as ImageBlockModel | null; const imageModel = doc.getBlockById(blockId) as ImageBlockModel | null;
if (sourceId && imageModel) {
doc.withoutTransact(() => { const props: Partial<ImageBlockProps> = {
if (!imageModel) {
return;
}
doc.updateBlock(imageModel, {
sourceId, 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 file = new File([blob], 'image.png', { type: blob.type });
const size = await readImageSize(file); const size = await readImageSize(file);
block.doc.updateBlock(model, { const bound = model.elementBound;
const props: Partial<ImageBlockProps> = {
width: size.width, width: size.width,
height: size.height, 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> { function convertToPng(blob: Blob): Promise<Blob | null> {
@@ -401,7 +425,7 @@ export async function turnImageIntoCardView(
transformModel(model, 'affine:attachment', attachmentProp); transformModel(model, 'affine:attachment', attachmentProp);
} }
export function readImageSize(file: File) { export function readImageSize(file: File | Blob) {
return new Promise<{ width: number; height: number }>(resolve => { return new Promise<{ width: number; height: number }>(resolve => {
const size = { width: 0, height: 0 }; const size = { width: 0, height: 0 };
const img = new Image(); const img = new Image();

View File

@@ -1,24 +1,37 @@
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import { RootBlockModel } from '@blocksuite/affine-model';
import { import {
DocModeExtension, DocModeExtension,
DocModeProvider, DocModeProvider,
EditorSettingExtension, EditorSettingExtension,
EditorSettingProvider, EditorSettingProvider,
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { SpecProvider } from '@blocksuite/affine-shared/utils'; import { matchModels, SpecProvider } from '@blocksuite/affine-shared/utils';
import { BlockStdScope, LifeCycleWatcher } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { import {
type BlockViewType, type BlockComponent,
type Query, BlockStdScope,
type SliceSnapshot, BlockViewIdentifier,
LifeCycleWatcher,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import type {
BlockModel,
BlockViewType,
ExtensionType,
Query,
SliceSnapshot,
} from '@blocksuite/store'; } from '@blocksuite/store';
import { signal } from '@preact/signals-core'; import { signal } from '@preact/signals-core';
import { literal } from 'lit/static-html.js';
import type { AffineDragHandleWidget } from '../drag-handle.js'; import type { AffineDragHandleWidget } from '../drag-handle.js';
import { getSnapshotRect } from '../utils.js'; import { getSnapshotRect } from '../utils.js';
export class PreviewHelper { 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( const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map(
id => ({ id => ({
id, id,
@@ -39,11 +52,28 @@ export class PreviewHelper {
// The children of the selected blocks should be rendered as Display // The children of the selected blocks should be rendered as Display
const addChildren = (id: string) => { const addChildren = (id: string) => {
const children = this.widget.doc.getBlock(id)?.model.children ?? []; const model = this.widget.doc.getBlock(id)?.model;
children.forEach(child => { if (!model) {
ids.push({ viewType: 'display', id: child.id }); return;
addChildren(child.id); }
});
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); selectedIds.forEach(addChildren);
@@ -61,23 +91,42 @@ export class PreviewHelper {
const widget = this.widget; const widget = this.widget;
const std = widget.std; const std = widget.std;
const sourceGfx = std.get(GfxControllerIdentifier); 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 docModeService = std.get(DocModeProvider);
const editorSetting = std.get(EditorSettingProvider).peek(); 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 store = widget.doc.doc.getStore({ query });
const previewSpec = SpecProvider.getInstance().getSpec( const previewSpec = SpecProvider.getInstance().getSpec(
mode === 'edgeless' ? 'edgeless:preview' : 'page:preview' isEdgeless ? 'edgeless:preview' : 'page:preview'
); );
const settingSignal = signal({ ...editorSetting }); const settingSignal = signal({ ...editorSetting });
const extensions = [ const extensions = [
DocModeExtension(docModeService), DocModeExtension(docModeService),
EditorSettingExtension(settingSignal), 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 (isEdgeless) {
if (!blockIds.includes(sourceGfx.surface!.id)) {
blockIds.push(sourceGfx.surface!.id);
}
class PreviewViewportInitializer extends LifeCycleWatcher { class PreviewViewportInitializer extends LifeCycleWatcher {
static override key = 'preview-viewport-initializer'; static override key = 'preview-viewport-initializer';
@@ -91,9 +140,11 @@ export class PreviewHelper {
if (payload.view.model.flavour === 'affine:page') { if (payload.view.model.flavour === 'affine:page') {
const gfx = this.std.get(GfxControllerIdentifier); 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 width: number = 500;
let height; let height;
let scale = 1;
if (mode === 'page') { if (isEdgeless) {
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
} else {
const rect = getSnapshotRect(snapshot); const rect = getSnapshotRect(snapshot);
if (rect) { if (rect) {
width = rect.w; width = rect.w;
@@ -128,9 +177,14 @@ export class PreviewHelper {
} else { } else {
height = 500; height = 500;
} }
scale = sourceGfx.viewport.zoom;
} else {
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
} }
return { return {
scale,
previewStd, previewStd,
width, width,
height, height,
@@ -144,13 +198,14 @@ export class PreviewHelper {
mode: 'page' | 'edgeless'; mode: 'page' | 'edgeless';
}): void => { }): void => {
const { blockIds, snapshot, container, mode } = options; const { blockIds, snapshot, container, mode } = options;
const { previewStd, width, height } = this.getPreviewStd( const { previewStd, width, height, scale } = this.getPreviewStd(
blockIds, blockIds,
snapshot, snapshot,
mode mode
); );
const previewTemplate = previewStd.render(); const previewTemplate = previewStd.render();
container.style.transform = `scale(${scale})`;
container.style.width = `${width}px`; container.style.width = `${width}px`;
if (height) { if (height) {
container.style.height = `${height}px`; container.style.height = `${height}px`;

View File

@@ -349,11 +349,12 @@ export class DragEventWatcher {
return selectedElements; return selectedElements;
}; };
const toSnapshotRequiredBlocks = (models: string[]) => { const toSnapshotRequiredBlocks = (selectedModels: string[]) => {
let surfaceAdded = false; let surfaceAdded = false;
const blocks: BlockModel[] = []; const blocks: BlockModel[] = [];
const blocksUnderSurface: BlockModel[] = [];
models.forEach(id => { selectedModels.forEach(id => {
const model = this.gfx.getElementById(id); const model = this.gfx.getElementById(id);
if (!model) { if (!model) {
@@ -368,16 +369,19 @@ export class DragEventWatcher {
const parentModel = this.std.store.getParent(model); const parentModel = this.std.store.getParent(model);
if (matchModels(parentModel, [SurfaceBlockModel])) { if (matchModels(parentModel, [SurfaceBlockModel])) {
if (surfaceAdded) return; blocksUnderSurface.push(model);
surfaceAdded = true;
blocks.push(this.gfx.surface!);
} else { } else {
blocks.push(model); 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( const selectedElements = getElementsInContainer(
@@ -953,13 +957,19 @@ export class DragEventWatcher {
block.flavour === 'affine:attachment' || block.flavour === 'affine:attachment' ||
block.flavour.startsWith('affine:embed-') block.flavour.startsWith('affine:embed-')
) { ) {
const style = (block.props.style ?? 'vertical') as EmbedCardStyle; const style = 'vertical' as EmbedCardStyle;
block.props.style = style; block.props.style = style;
blockBound.w = EMBED_CARD_WIDTH[style]; blockBound.w = EMBED_CARD_WIDTH[style];
blockBound.h = EMBED_CARD_HEIGHT[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) { if (ignoreOriginalPos) {
blockBound.x = modelX; blockBound.x = modelX;
blockBound.y = modelY; blockBound.y = modelY;
@@ -1013,8 +1023,10 @@ export class DragEventWatcher {
.then(slices => { .then(slices => {
slices?.content.forEach((block, idx) => { slices?.content.forEach((block, idx) => {
if ( if (
block.flavour === 'affine:attachment' || block.id === content[idx].id &&
block.flavour.startsWith('affine:embed-') (block.flavour === 'affine:image' ||
block.flavour === 'affine:attachment' ||
block.flavour.startsWith('affine:embed-'))
) { ) {
store.updateBlock(block as BlockModel, { store.updateBlock(block as BlockModel, {
xywh: content[idx].props.xywh, xywh: content[idx].props.xywh,

View File

@@ -27,7 +27,7 @@ export class EdgelessWatcher {
private readonly _handleEdgelessToolUpdated = ( private readonly _handleEdgelessToolUpdated = (
newTool: GfxToolsFullOptionValue newTool: GfxToolsFullOptionValue
) => { ) => {
// @ts-expect-error FIXME: resolve after gfx tool refactor // @ts-expect-error GfxToolsFullOptionValue is extended in other packages
if (newTool.type === 'default') { if (newTool.type === 'default') {
this.updateAnchorElement(); this.updateAnchorElement();
} else { } else {

View File

@@ -19,6 +19,7 @@ import type { GfxViewportElement } from '@blocksuite/block-std/gfx';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { query, state } from 'lit/decorators.js'; import { query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockWidgetName } from '../types.js'; import type { EdgelessRootBlockWidgetName } from '../types.js';
import type { EdgelessRootService } from './edgeless-root-service.js'; import type { EdgelessRootService } from './edgeless-root-service.js';
@@ -229,8 +230,12 @@ export class EdgelessRootPreviewBlockComponent
} }
override renderBlock() { override renderBlock() {
const background = styleMap({
background: this.overrideBackground,
});
return html` return html`
<div class="edgeless-background edgeless-container"> <div class="edgeless-background edgeless-container" style=${background}>
<gfx-viewport <gfx-viewport
.enableChildrenSchedule=${!this._disableScheduleUpdate} .enableChildrenSchedule=${!this._disableScheduleUpdate}
.viewport=${this.service.viewport} .viewport=${this.service.viewport}
@@ -260,6 +265,9 @@ export class EdgelessRootPreviewBlockComponent
} }
} }
@state()
accessor overrideBackground: string | undefined = undefined;
@state() @state()
accessor editorViewportSelector = '.affine-edgeless-viewport'; accessor editorViewportSelector = '.affine-edgeless-viewport';

View File

@@ -1,3 +1,4 @@
export { FramePreview } from './components/frame/frame-preview.js'; export { FramePreview } from './components/frame/frame-preview.js';
export * from './edgeless-root-block.js'; export * from './edgeless-root-block.js';
export { EdgelessRootPreviewBlockComponent } from './edgeless-root-preview-block.js';
export { EdgelessRootService } from './edgeless-root-service.js'; export { EdgelessRootService } from './edgeless-root-service.js';

View File

@@ -46,7 +46,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
} }
`; `;
private readonly _hideOutsideBlock = requestThrottledConnectedFrame(() => { private readonly _hideOutsideBlock = () => {
if (this.getModelsInViewport && this.host) { if (this.getModelsInViewport && this.host) {
const host = this.host; const host = this.host;
const modelsInViewport = this.getModelsInViewport(); const modelsInViewport = this.getModelsInViewport();
@@ -73,7 +73,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
this._lastVisibleModels = modelsInViewport; this._lastVisibleModels = modelsInViewport;
} }
}, this); };
private _lastVisibleModels?: Set<GfxBlockElementModel>; private _lastVisibleModels?: Set<GfxBlockElementModel>;
@@ -95,14 +95,13 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
const viewportUpdateCallback = () => { const viewportUpdateCallback = () => {
this._refreshViewport(); this._refreshViewport();
this._hideOutsideBlock();
}; };
if (!this.enableChildrenSchedule) { if (!this.enableChildrenSchedule) {
delete this.scheduleUpdateChildren; delete this.scheduleUpdateChildren;
} }
viewportUpdateCallback(); this._hideOutsideBlock();
this.disposables.add( this.disposables.add(
this.viewport.viewportUpdated.on(() => viewportUpdateCallback()) this.viewport.viewportUpdated.on(() => viewportUpdateCallback())
); );