mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 02:26:21 +08:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user