diff --git a/blocksuite/affine/block-surface-ref/src/surface-ref-block.ts b/blocksuite/affine/block-surface-ref/src/surface-ref-block.ts index 6e2c3ba612..312fa9b478 100644 --- a/blocksuite/affine/block-surface-ref/src/surface-ref-block.ts +++ b/blocksuite/affine/block-surface-ref/src/surface-ref-block.ts @@ -13,25 +13,22 @@ import { import { Peekable } from '@blocksuite/affine-components/peek'; import { FrameBlockModel, - GroupElementModel, + RootBlockModel, type SurfaceRefBlockModel, } from '@blocksuite/affine-model'; import { DocModeProvider, - EditorSettingExtension, - EditorSettingProvider, EditPropsStore, - GeneralSettingSchema, ThemeProvider, } from '@blocksuite/affine-shared/services'; import { + matchModels, requestConnectedFrame, SpecProvider, } from '@blocksuite/affine-shared/utils'; import { BlockComponent, BlockSelection, - BlockServiceWatcher, BlockStdScope, type EditorHost, LifeCycleWatcher, @@ -40,8 +37,8 @@ import { import { GfxBlockElementModel, GfxControllerIdentifier, - GfxExtension, type GfxModel, + GfxPrimitiveElementModel, } from '@blocksuite/block-std/gfx'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { @@ -52,12 +49,10 @@ import { type SerializedXYWH, } from '@blocksuite/global/utils'; import type { BaseSelection, Store } from '@blocksuite/store'; -import { signal } from '@preact/signals-core'; import { css, html, nothing, type TemplateResult } from 'lit'; import { query, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; -import type { EdgelessPreviewer } from './types.js'; import { noContentPlaceholder } from './utils.js'; const REF_LABEL_ICON = { @@ -427,48 +422,26 @@ export class SurfaceRefBlockComponent extends BlockComponent { - const edgelessBlock = component as BlockComponent & - EdgelessPreviewer; - - edgelessBlock.editorViewportSelector = 'ref-viewport'; - refreshViewport(); - const gfx = edgelessBlock.std.get(GfxControllerIdentifier); - gfx.viewport.sizeUpdated.once(() => { + const disposable = this.std.view.viewUpdated.on(payload => { + if ( + payload.type === 'add' && + matchModels(payload.view.model, [RootBlockModel]) + ) { + disposable.dispose(); + queueMicrotask(() => refreshViewport()); + const gfx = this.std.get(GfxControllerIdentifier); + gfx.viewport.sizeUpdated.on(() => { refreshViewport(); }); - }) - ); + } + }); } } - - class ViewportInitializer extends GfxExtension { - static override readonly key = 'surface-ref-viewport-initializer'; - - override mounted() { - this.gfx.viewport.setViewportByBound( - Bound.deserialize(self._referenceXYWH!) - ); - refreshViewport(); - } - } - - this._previewSpec.extend([ - ViewportInitializer, - PageViewWatcher, - EditorSettingExtension(editorSetting), - ]); + this._previewSpec.extend([SurfaceRefViewportInitializer]); const referenceId = this.model.reference; const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => { @@ -501,7 +474,7 @@ export class SurfaceRefBlockComponent extends BlockComponent { if ( @@ -513,8 +486,6 @@ export class SurfaceRefBlockComponent extends BlockComponent new SurfaceBlockTransformer(), + transformer: transformerConfigs => + new SurfaceBlockTransformer(transformerConfigs), toModel: () => new SurfaceBlockModel(), }); diff --git a/blocksuite/affine/block-surface/src/surface-transformer.ts b/blocksuite/affine/block-surface/src/surface-transformer.ts index aa50b1acab..325680cf13 100644 --- a/blocksuite/affine/block-surface/src/surface-transformer.ts +++ b/blocksuite/affine/block-surface/src/surface-transformer.ts @@ -92,9 +92,18 @@ export class SurfaceBlockTransformer extends BaseBlockTransformer = {}; + /** + * When the selectedElements is defined, only the selected elements will be serialized. + */ + const selectedElements = this.transformerConfigs.get( + 'selectedElements' + ) as Set; + if (elementsValue) { elementsValue.forEach((element, key) => { - value[key] = this._elementToJSON(element as Y.Map); + if (selectedElements?.has(key) || !selectedElements) { + value[key] = this._elementToJSON(element as Y.Map); + } }); } snapshot.props = { diff --git a/blocksuite/affine/model/src/blocks/attachment/attachment-model.ts b/blocksuite/affine/model/src/blocks/attachment/attachment-model.ts index bb82c69d2f..bb0d293998 100644 --- a/blocksuite/affine/model/src/blocks/attachment/attachment-model.ts +++ b/blocksuite/affine/model/src/blocks/attachment/attachment-model.ts @@ -81,7 +81,8 @@ export const AttachmentBlockSchema = defineBlockSchema({ 'affine:list', ], }, - transformer: () => new AttachmentBlockTransformer(), + transformer: transformerConfigs => + new AttachmentBlockTransformer(transformerConfigs), toModel: () => new AttachmentBlockModel(), }); diff --git a/blocksuite/affine/model/src/blocks/image/image-model.ts b/blocksuite/affine/model/src/blocks/image/image-model.ts index a10db389d7..9856d72dd2 100644 --- a/blocksuite/affine/model/src/blocks/image/image-model.ts +++ b/blocksuite/affine/model/src/blocks/image/image-model.ts @@ -35,7 +35,8 @@ export const ImageBlockSchema = defineBlockSchema({ version: 1, role: 'content', }, - transformer: () => new ImageBlockTransformer(), + transformer: transformerConfigs => + new ImageBlockTransformer(transformerConfigs), toModel: () => new ImageBlockModel(), }); diff --git a/blocksuite/affine/widget-drag-handle/src/drag-handle.ts b/blocksuite/affine/widget-drag-handle/src/drag-handle.ts index bad742053e..a41bdc28af 100644 --- a/blocksuite/affine/widget-drag-handle/src/drag-handle.ts +++ b/blocksuite/affine/widget-drag-handle/src/drag-handle.ts @@ -4,10 +4,9 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services'; import { isInsideEdgelessEditor, isInsidePageEditor, - isTopLevelBlock, } from '@blocksuite/affine-shared/utils'; import { type BlockComponent, WidgetComponent } from '@blocksuite/block-std'; -import type { GfxBlockElementModel } from '@blocksuite/block-std/gfx'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; import { DisposableGroup, type IVec, @@ -15,8 +14,9 @@ import { type Rect, } from '@blocksuite/global/utils'; import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; -import { html } from 'lit'; +import { html, nothing } from 'lit'; import { query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js'; @@ -53,12 +53,13 @@ export class AffineDragHandleWidget extends WidgetComponent { this.dragHoverRect = null; this.anchorBlockId.value = null; this.isDragHandleHovered = false; - this.isHoverDragHandleVisible = false; - this.isTopLevelDragHandleVisible = false; this.pointerEventWatcher.reset(); }; + @state() + accessor activeDragHandle: 'block' | 'gfx' | null = null; + anchorBlockId = signal(null); anchorBlockComponent = computed(() => { @@ -67,16 +68,14 @@ export class AffineDragHandleWidget extends WidgetComponent { return this.std.view.getBlock(this.anchorBlockId.value); }); - anchorEdgelessElement: ReadonlySignal = computed( - () => { - if (!this.anchorBlockId.value) return null; - if (this.mode === 'page') return null; + anchorEdgelessElement: ReadonlySignal = computed(() => { + if (!this.anchorBlockId.value) return null; + if (this.mode === 'page') return null; - const crud = this.std.get(EdgelessCRUDIdentifier); - const edgelessElement = crud.getElementById(this.anchorBlockId.value); - return isTopLevelBlock(edgelessElement) ? edgelessElement : null; - } - ); + const crud = this.std.get(EdgelessCRUDIdentifier); + const edgelessElement = crud.getElementById(this.anchorBlockId.value); + return edgelessElement; + }); // Single block: drag handle should show on the vertical middle of the first line of element center: IVec = [0, 0]; @@ -115,15 +114,18 @@ export class AffineDragHandleWidget extends WidgetComponent { if (this.dragging && !force) return; updateDragHandleClassName(); - this.isHoverDragHandleVisible = false; - this.isTopLevelDragHandleVisible = false; this.isDragHandleHovered = false; this.anchorBlockId.value = null; + this.activeDragHandle = null; if (this.dragHandleContainer) { + this.dragHandleContainer.removeAttribute('style'); this.dragHandleContainer.style.display = 'none'; } + if (this.dragHandleGrabber) { + this.dragHandleGrabber.removeAttribute('style'); + } if (force) { this._reset(); @@ -132,9 +134,13 @@ export class AffineDragHandleWidget extends WidgetComponent { isDragHandleHovered = false; - isHoverDragHandleVisible = false; + get isBlockDragHandleVisible() { + return this.activeDragHandle === 'block'; + } - isTopLevelDragHandleVisible = false; + get isGfxDragHandleVisible() { + return this.activeDragHandle === 'gfx'; + } noteScale = signal(1); @@ -190,7 +196,7 @@ export class AffineDragHandleWidget extends WidgetComponent { override render() { const hoverRectStyle = styleMap( - this.dragHoverRect + this.dragHoverRect && this.activeDragHandle ? { width: `${this.dragHoverRect.width}px`, height: `${this.dragHoverRect.height}px`, @@ -201,11 +207,27 @@ export class AffineDragHandleWidget extends WidgetComponent { display: 'none', } ); + const isGfx = this.activeDragHandle === 'gfx'; + const classes = { + 'affine-drag-handle-grabber': true, + dots: isGfx ? true : false, + }; return html`
-
-
+
+
+ ${isGfx + ? html` +
+
+
+
+
+
+ ` + : nothing} +
diff --git a/blocksuite/affine/widget-drag-handle/src/helpers/rect-helper.ts b/blocksuite/affine/widget-drag-handle/src/helpers/rect-helper.ts index 400dfa35b6..cda56cb865 100644 --- a/blocksuite/affine/widget-drag-handle/src/helpers/rect-helper.ts +++ b/blocksuite/affine/widget-drag-handle/src/helpers/rect-helper.ts @@ -15,7 +15,7 @@ import { export class RectHelper { private readonly _getHoveredBlocks = (): BlockComponent[] => { - if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId) + if (!this.widget.isBlockDragHandleVisible || !this.widget.anchorBlockId) return []; const hoverBlock = this.widget.anchorBlockComponent.peek(); diff --git a/blocksuite/affine/widget-drag-handle/src/middleware/blocks-filter.ts b/blocksuite/affine/widget-drag-handle/src/middleware/blocks-filter.ts new file mode 100644 index 0000000000..963cb457b6 --- /dev/null +++ b/blocksuite/affine/widget-drag-handle/src/middleware/blocks-filter.ts @@ -0,0 +1,49 @@ +import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { isGfxGroupCompatibleModel } from '@blocksuite/block-std/gfx'; +import type { TransformerMiddleware } from '@blocksuite/store'; + +/** + * Used to filter out gfx elements that are not selected + * @param ids + * @param std + * @returns + */ +export const gfxBlocksFilter = ( + ids: string[], + std: BlockStdScope +): TransformerMiddleware => { + const selectedIds = new Set(); + const store = std.store; + const surface = store.getBlocksByFlavour('affine:surface')[0] + .model as SurfaceBlockModel; + const idsToCheck = ids.slice(); + + for (const id of idsToCheck) { + const blockOrElem = store.getBlock(id)?.model ?? surface.getElementById(id); + + if (!blockOrElem) continue; + + if (isGfxGroupCompatibleModel(blockOrElem)) { + idsToCheck.push(...blockOrElem.childIds); + } + + selectedIds.add(id); + } + + return ({ slots, transformerConfigs }) => { + slots.beforeExport.on(payload => { + if (payload.type !== 'block') { + return; + } + + if (payload.model.flavour === 'affine:surface') { + transformerConfigs.set('selectedElements', selectedIds); + payload.model.children = payload.model.children.filter(model => + selectedIds.has(model.id) + ); + return; + } + }); + }; +}; diff --git a/blocksuite/affine/widget-drag-handle/src/styles.ts b/blocksuite/affine/widget-drag-handle/src/styles.ts index ba63d866d8..7d61fb3b39 100644 --- a/blocksuite/affine/widget-drag-handle/src/styles.ts +++ b/blocksuite/affine/widget-drag-handle/src/styles.ts @@ -1,3 +1,4 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { css } from 'lit'; import { DRAG_HANDLE_CONTAINER_WIDTH } from './config.js'; @@ -35,6 +36,32 @@ export const styles = css` transition: width 0.25s ease; } + .affine-drag-handle-grabber.dots { + width: 14px; + height: 26px; + box-sizing: border-box; + padding: 5px 2px; + border-radius: 4px; + gap: 2px; + display: flex; + flex-wrap: wrap; + background-color: transparent; + transform: translateX(-100%); + transition: unset; + } + + .affine-drag-handle-grabber.dots:hover { + background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + + .affine-drag-handle-grabber.dots > .dot { + width: 4px; + height: 4px; + border-radius: 50%; + flex: 0 0 4px; + background-color: ${unsafeCSSVarV2('icon/secondary')}; + } + @media print { .affine-drag-handle-widget { display: none; diff --git a/blocksuite/affine/widget-drag-handle/src/utils.ts b/blocksuite/affine/widget-drag-handle/src/utils.ts index 8ad72ddeae..4b3a45c086 100644 --- a/blocksuite/affine/widget-drag-handle/src/utils.ts +++ b/blocksuite/affine/widget-drag-handle/src/utils.ts @@ -151,7 +151,7 @@ export const isOutOfNoteBlock = ( }; export const getParentNoteBlock = (blockComponent: BlockComponent) => { - return blockComponent.closest('affine-note') ?? null; + return blockComponent.closest('affine-note, affine-edgeless-note') ?? null; }; export const getClosestNoteBlock = ( diff --git a/blocksuite/affine/widget-drag-handle/src/watchers/drag-event-watcher.ts b/blocksuite/affine/widget-drag-handle/src/watchers/drag-event-watcher.ts index 4d9ce6e2dd..72ca8b2c74 100644 --- a/blocksuite/affine/widget-drag-handle/src/watchers/drag-event-watcher.ts +++ b/blocksuite/affine/widget-drag-handle/src/watchers/drag-event-watcher.ts @@ -1,17 +1,16 @@ import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph'; -import { - addNoteAtPoint, - getSurfaceBlock, - SurfaceBlockModel, -} from '@blocksuite/affine-block-surface'; +import { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; import { DropIndicator } from '@blocksuite/affine-components/drop-indicator'; import { AttachmentBlockModel, BookmarkBlockModel, DatabaseBlockModel, + DEFAULT_NOTE_HEIGHT, + DEFAULT_NOTE_WIDTH, type EmbedCardStyle, ListBlockModel, NoteBlockModel, + RootBlockModel, } from '@blocksuite/affine-model'; import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, @@ -31,19 +30,39 @@ import { matchModels, } from '@blocksuite/affine-shared/utils'; import { - type BlockComponent, + BlockComponent, type BlockStdScope, type DragFromBlockSuite, type DragPayload, type DropPayload, - isGfxBlockComponent, } from '@blocksuite/block-std'; -import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; -import { Bound, last, Point, Rect } from '@blocksuite/global/utils'; -import { Slice, type SliceSnapshot } from '@blocksuite/store'; +import { + GfxControllerIdentifier, + GfxGroupLikeElementModel, + type GfxModel, + GfxPrimitiveElementModel, + isGfxGroupCompatibleModel, +} from '@blocksuite/block-std/gfx'; +import { + assertType, + Bound, + groupBy, + last, + Point, + Rect, + type SerializedXYWH, +} from '@blocksuite/global/utils'; +import { + type BlockModel, + type BlockSnapshot, + Slice, + type SliceSnapshot, + toDraftModel, +} from '@blocksuite/store'; import type { AffineDragHandleWidget } from '../drag-handle.js'; import { PreviewHelper } from '../helpers/preview-helper.js'; +import { gfxBlocksFilter } from '../middleware/blocks-filter.js'; import { newIdCrossDoc } from '../middleware/new-id-cross-doc.js'; import { reorderList } from '../middleware/reorder-list'; import { surfaceRefToEmbed } from '../middleware/surface-ref-to-embed.js'; @@ -57,6 +76,10 @@ import { export type DragBlockEntity = { type: 'blocks'; + /** + * The mode that the blocks are dragged from + */ + fromMode?: 'block' | 'gfx'; snapshot?: SliceSnapshot; modelIds: string[]; }; @@ -87,35 +110,10 @@ export class DragEventWatcher { return this.widget.std; } - private get _gfx() { + get gfx() { return this.widget.std.get(GfxControllerIdentifier); } - private readonly _computeEdgelessBound = ( - x: number, - y: number, - width: number, - height: number - ) => { - const border = 2; - const noteScale = this.widget.noteScale.peek(); - const { viewport } = this._gfx; - const { left: viewportLeft, top: viewportTop } = viewport; - const currentViewBound = new Bound( - x - viewportLeft, - y - viewportTop, - width + border / noteScale, - height + border / noteScale - ); - const currentModelBound = viewport.toModelBound(currentViewBound); - return new Bound( - currentModelBound.x, - currentModelBound.y, - width * noteScale, - height * noteScale - ); - }; - private readonly _createDropIndicator = () => { if (!this.dropIndicator) { this.dropIndicator = new DropIndicator(); @@ -146,6 +144,23 @@ export class DragEventWatcher { this._updateDropIndicator(point, payload, dropPayload, block); }; + private readonly _getFallbackInsertPlace = (block: BlockModel) => { + const store = this.std.store; + let curBlock: BlockModel | null = block; + + while (curBlock) { + const parent = store.getParent(curBlock); + + if (parent && matchModels(parent, [NoteBlockModel])) { + return curBlock; + } + + curBlock = parent; + } + + return null; + }; + /** * When dragging, should update indicator position and target drop block id */ @@ -167,36 +182,63 @@ export class DragEventWatcher { const isDropOnNoteBlock = matchModels(model, [NoteBlockModel]); + const schema = this.std.store.schema; const edge = dropPayload.edge; const scale = this.widget.scale.peek(); - let result: DropResult; + let result: DropResult | null = null; if (edge === 'right' && matchModels(dropBlock.model, [ListBlockModel])) { const domRect = getRectByBlockComponent(dropBlock); const placement = 'in'; - const rect = Rect.fromLWTH( - domRect.left + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, - domRect.width - BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, - domRect.top + domRect.height, - 3 * scale - ); - result = { - placement, - rect, - modelState: { - model: dropBlock.model, - rect: domRect, - element: dropBlock, - }, - }; + if ( + snapshot.content.every(block => + schema.safeValidate(block.flavour, 'affine:list') + ) + ) { + const rect = Rect.fromLWTH( + domRect.left + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, + domRect.width - BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, + domRect.top + domRect.height, + 3 * scale + ); + + result = { + placement, + rect, + modelState: { + model: dropBlock.model, + rect: domRect, + element: dropBlock, + }, + }; + } else { + const fallbackModel = this._getFallbackInsertPlace(dropBlock.model); + + if (fallbackModel) { + const fallbackModelView = this.std.view.getBlock(fallbackModel.id)!; + const domRect = fallbackModelView?.getBoundingClientRect(); + + result = { + placement: 'after', + rect: Rect.fromLWTH( + domRect.left, + domRect.width, + domRect.top + domRect.height, + 3 * scale + ), + modelState: { + model: fallbackModel, + rect: domRect, + element: fallbackModelView, + }, + }; + } + } } else { const placement = isDropOnNoteBlock && - this.widget.doc.schema.safeValidate( - snapshot.content[0].flavour, - 'affine:note' - ) + schema.safeValidate(snapshot.content[0].flavour, 'affine:note') ? 'in' : edge === 'top' ? 'before' @@ -266,17 +308,87 @@ export class DragEventWatcher { } }; - private readonly _getSnapshotFromHoveredBlocks = () => { - const hoverBlock = this.widget.anchorBlockComponent.peek()!; - const isInSurface = isGfxBlockComponent(hoverBlock); + private readonly _getSnapshotFromSelectedGfxElms = () => { + const selectedElmId = this.widget.anchorBlockId.peek()!; + const selectedElm = this.gfx.getElementById(selectedElmId); - if (isInSurface) { + if (!selectedElm) { return { - models: [hoverBlock.model], - snapshot: this._toSnapshot([hoverBlock]), + snapshot: undefined, }; } + const getElementsInContainer = ( + elem: GfxModel, + selectedElements: string[] = [] + ) => { + selectedElements.push(elem.id); + + if (isGfxGroupCompatibleModel(elem)) { + elem.childElements.forEach(child => { + getElementsInContainer(child, selectedElements); + }); + } + + return selectedElements; + }; + const toSnapshotRequiredBlocks = (models: string[]) => { + let surfaceAdded = false; + const blocks: BlockModel[] = []; + + models.forEach(id => { + const model = this.gfx.getElementById(id); + + if (!model) { + return; + } + + if (model instanceof GfxPrimitiveElementModel) { + if (surfaceAdded) return; + surfaceAdded = true; + blocks.push(this.gfx.surface!); + } else { + const parentModel = this.std.store.getParent(model); + + if (matchModels(parentModel, [SurfaceBlockModel])) { + if (surfaceAdded) return; + surfaceAdded = true; + blocks.push(this.gfx.surface!); + } else { + blocks.push(model); + } + } + }); + + return blocks; + }; + + const selectedElements = getElementsInContainer( + selectedElm as GfxModel, + [] + ); + const blocksOfSnapshot = toSnapshotRequiredBlocks(selectedElements); + + return { + snapshot: this._toSnapshot(blocksOfSnapshot, [selectedElmId]), + }; + }; + + private readonly _getDraggedSnapshot = () => { + const { snapshot } = + this.widget.activeDragHandle === 'block' + ? this._getSnapshotFromHoveredBlocks() + : this._getSnapshotFromSelectedGfxElms(); + + return { + fromMode: this.widget.activeDragHandle!, + snapshot, + }; + }; + + private readonly _getSnapshotFromHoveredBlocks = () => { + const hoverBlock = this.widget.anchorBlockComponent.peek()!; + let selections = this.widget.selectionHelper.selectedBlocks; // When current selection is TextSelection @@ -338,51 +450,194 @@ export class DragEventWatcher { ) as BlockComponent[]; return { - models: blocksExcludingChildren.map(block => block.model), snapshot: this._toSnapshot(blocksExcludingChildren), }; }; - private readonly _onDrop = ( + private readonly _onEdgelessDrop = ( dropBlock: BlockComponent, dragPayload: DragBlockPayload, dropPayload: DropPayload, point: Point + ) => { + /** + * When drag gfx elements from edgeless editor to other editor, there's some limitation: + * - when drop on the place other than note block, edgeless content can't be drag and drop within the same doc + * - when drop on note block, handle the drop data in the same way as it in page editor, + * and it will filter out the block that can't be placed under note block + * - drop data will be wrapped in a note block if it can't be placed under surface block or root block + */ + if (matchModels(dropBlock.model, [RootBlockModel])) { + // can't drop edgeless content on the same doc + if ( + dragPayload.bsEntity?.fromMode === 'gfx' && + dragPayload.from?.docId === this.widget.doc.id + ) { + return; + } + + const surfaceBlockModel = this.gfx.surface; + const snapshot = dragPayload?.bsEntity?.snapshot; + + if (!snapshot || !surfaceBlockModel) { + return; + } + + if (dragPayload.bsEntity?.fromMode === 'gfx') { + this._mergeSnapshotToCurDoc(snapshot, point).catch(console.error); + } else { + this._dropAsGfxBlock(snapshot, point); + } + } else { + this._onPageDrop(dropBlock, dragPayload, dropPayload, point); + } + }; + + private readonly _onPageDrop = ( + dropBlock: BlockComponent, + dragPayload: DragBlockPayload, + dropPayload: DropPayload, + _: Point ) => { const result = this._getDropResult(dropBlock, dragPayload, dropPayload); const snapshot = dragPayload?.bsEntity?.snapshot; if (!result || !snapshot || snapshot.content.length === 0) return; - { - const isEdgelessContainer = dropBlock.closest('.edgeless-container'); - if (isEdgelessContainer) { - // drop to edgeless container - this._onDropOnEdgelessCanvas( - dropBlock, - dragPayload, - dropPayload, - point - ); - return; - } - } - + const store = this.std.store; + const schema = store.schema; const model = result.modelState.model; const parent = - result.placement === 'in' ? model : this.std.store.getParent(model); - - if (!parent) return; - if (matchModels(parent, [SurfaceBlockModel])) { - return; - } - + result.placement === 'in' ? model : this.std.store.getParent(model)!; const index = result.placement === 'in' ? 0 : parent.children.indexOf(model) + (result.placement === 'before' ? 0 : 1); + if (!parent) return; + + if (dragPayload.bsEntity?.fromMode === 'gfx') { + if (!matchModels(parent, [NoteBlockModel])) { + return; + } + + // if not all blocks can be dropped in note block, merge the snapshot to the current doc + if ( + !snapshot.content.every(block => + schema.safeValidate(block.flavour, 'affine:note') + ) && + // if all blocks are note blocks, merge it to the current parent note + !snapshot.content.every(block => block.flavour === 'affine:note') + ) { + // merge the snapshot to the current doc if the snapshot comes from other doc + if (dragPayload.from?.docId !== this.widget.doc.id) { + this._mergeSnapshotToCurDoc(snapshot) + .then(idRemap => { + let largestElem!: { + size: number; + id: string; + }; + + idRemap.forEach(val => { + const gfxElement = this.gfx.getElementById(val) as GfxModel; + + if (gfxElement?.elementBound) { + const elemBound = gfxElement.elementBound; + largestElem = + (largestElem?.size ?? 0) < elemBound.w * elemBound.h + ? { size: elemBound.w * elemBound.h, id: val } + : largestElem; + } + }); + + if (!largestElem) { + store.addBlock( + 'affine:embed-linked-doc', + { + pageId: store.doc.id, + }, + parent.id, + index + ); + } else { + store.addBlock( + 'affine:surface-ref', + { + reference: largestElem.id, + }, + parent.id, + index + ); + } + }) + .catch(console.error); + } + // otherwise, just to create a surface-ref block + else { + let largestElem!: { + size: number; + id: string; + }; + + const walk = (block: BlockSnapshot) => { + if (block.flavour === 'affine:surface') { + Object.values( + block.props.elements as Record< + string, + { id: string; xywh: SerializedXYWH } + > + ).forEach(elem => { + if (elem.xywh) { + const bound = Bound.deserialize(elem.xywh); + const size = bound.w * bound.h; + if ((largestElem?.size ?? 0) < size) { + largestElem = { size, id: elem.id }; + } + } + }); + block.children.forEach(walk); + } else { + if (block.props.xywh) { + const bound = Bound.deserialize( + block.props.xywh as SerializedXYWH + ); + const size = bound.w * bound.h; + if ((largestElem?.size ?? 0) < size) { + largestElem = { size, id: block.id }; + } + } + } + }; + + snapshot.content.forEach(walk); + + if (largestElem) { + store.addBlock( + 'affine:surface-ref', + { + reference: largestElem.id, + }, + parent.id, + index + ); + } else { + store.addBlock( + 'affine:embed-linked-doc', + { + pageId: store.doc.id, + }, + parent.id, + index + ); + } + } + + return; + } + } + + // drop a note on other note if (matchModels(parent, [NoteBlockModel])) { const [first] = snapshot.content; if (first.flavour === 'affine:note') { @@ -393,6 +648,7 @@ export class DragEventWatcher { } } + // drop on the same place, do nothing if ( (dragPayload.from?.docId === this.widget.doc.id && result.placement === 'after' && @@ -406,6 +662,19 @@ export class DragEventWatcher { this._dropToModel(snapshot, parent.id, index).catch(console.error); }; + private readonly _onDrop = ( + dropBlock: BlockComponent, + dragPayload: DragBlockPayload, + dropPayload: DropPayload, + point: Point + ) => { + if (this.mode === 'edgeless') { + this._onEdgelessDrop(dropBlock, dragPayload, dropPayload, point); + } else { + this._onPageDrop(dropBlock, dragPayload, dropPayload, point); + } + }; + private readonly _onDropNoteOnNote = ( snapshot: SliceSnapshot, parent?: string, @@ -431,117 +700,403 @@ export class DragEventWatcher { .catch(console.error); }; - private readonly _onDropOnEdgelessCanvas = ( - dropBlock: BlockComponent, - dragPayload: DragBlockPayload, - dropPayload: DropPayload, - point: Point + /** + * Merge the snapshot into the current existing surface model and page model. + * This method does the following: + * 1. Analyze the snapshot to build the container dependency tree + * 2. Merge the snapshot in the correct order to make sure all containers are created after their children + * @param snapshot + * @param point + */ + private readonly _mergeSnapshotToCurDoc = async ( + snapshot: SliceSnapshot, + point?: Point ) => { - const surfaceBlockModel = getSurfaceBlock(this.widget.doc); - const result = this._getDropResult(dropBlock, dragPayload, dropPayload); - - const snapshot = dragPayload?.bsEntity?.snapshot; - - if (!result || !snapshot || !surfaceBlockModel) { - return; + if (!point) { + const bound = this.gfx.elementsBound; + point = new Point(bound.x + bound.w, bound.y + bound.h / 2); + } else { + point = Point.from( + this.gfx.viewport.toModelCoordFromClientCoord([point.x, point.y]) + ); } - const [first] = snapshot.content; - if (first.flavour === 'affine:note') return; + this._rewriteSnapshotXYWH(snapshot, point); - if (snapshot.content.length === 1) { - const importToSurface = ( - width: number, - height: number, - newBound: Bound - ) => { - first.props.xywh = newBound.serialize(); - first.props.width = width; - first.props.height = height; + const surface = this.gfx.surface!; + const root = this.std.store.root!; + const schema = this.std.store.schema; + const containerTree: Record> = { root: new Set() }; + const idRemap = new Map(); + let elemMap: Record< + string, + { type: string; children?: { json: Record } } + > = {}; + const blockMap: Record< + string, + { + surfaceChild: boolean; + snapshot: BlockSnapshot; + } + > = {}; - const std = this.std; - const job = this._getJob(); - job - .snapshotToSlice(snapshot, std.store, surfaceBlockModel.id) - .catch(console.error); - }; + const isGroupLikeElem = (elem: { type: string }) => { + const constructor = surface.getConstructor(elem.type); + const isGroup = Object.isPrototypeOf.call( + GfxGroupLikeElementModel.prototype, + constructor + ); - if ( - ['affine:attachment', 'affine:bookmark'].includes(first.flavour) || - first.flavour.startsWith('affine:embed-') - ) { - const style = (first.props.style ?? 'horizontal') as EmbedCardStyle; - const width = EMBED_CARD_WIDTH[style]; - const height = EMBED_CARD_HEIGHT[style]; + return isGroup; + }; + const isGroupLikeBlock = (flavour: string) => { + const blockModel = schema.get(flavour)?.model.toModel?.(); - const newBound = this._computeEdgelessBound( - point.x, - point.y, - width, - height + return blockModel && isGfxGroupCompatibleModel(blockModel); + }; + + // walk through the snapshot to build the container dependency tree + const buildContainerTree = (block: BlockSnapshot) => { + if (block.flavour === 'affine:surface') { + elemMap = (block.props.elements as typeof elemMap) ?? {}; + Object.entries(elemMap).forEach(([elemId, elem]) => { + if (isGroupLikeElem(elem)) { + // only add the group to the root if it's not a child of any other element + if ( + Object.values(containerTree).every( + childSet => !childSet.has(elemId) + ) + ) { + containerTree['root'].add(elem.type); + } + + Object.keys(elem.children?.json ?? {}).forEach(childId => { + containerTree[elemId] = containerTree[elemId] ?? new Set(); + containerTree[elemId].add(childId); + // if the child was already added to the root, remove it + containerTree['root'].delete(childId); + }); + } else { + containerTree['root'].add(elemId); + } + }); + + block.children?.forEach(buildContainerTree); + } else { + const isSurfaceChild = schema.safeValidate( + block.flavour, + 'affine:surface' ); - if (!newBound) return; + blockMap[block.id] = { + surfaceChild: isSurfaceChild, + snapshot: block, + }; - if (first.flavour === 'affine:embed-linked-doc') { - this._trackLinkedDocCreated(first.id); + if ( + Object.values(containerTree).every( + childSet => !childSet.has(block.id) + ) + ) { + containerTree['root'].add(block.id); } - importToSurface(width, height, newBound); - return; + if (isGroupLikeBlock(block.flavour)) { + Object.keys(block.props.childElementIds ?? {}).forEach(childId => { + containerTree[block.id] = containerTree[block.id] ?? new Set(); + containerTree[block.id].add(childId); + // if the child was already added to the root, remove it + containerTree['root'].delete(childId); + }); + } + } + }; + + snapshot.content.forEach(buildContainerTree); + + const addInDependencyOrder = async (id: string) => { + if (containerTree[id]) { + for (const childId of containerTree[id]) { + await addInDependencyOrder(childId); + } } - if (first.flavour === 'affine:image') { - const noteScale = this.widget.noteScale.peek(); - const width = Number(first.props.width || 100) * noteScale; - const height = Number(first.props.height || 100) * noteScale; + if (blockMap[id]) { + const { surfaceChild, snapshot: blockSnapshot } = blockMap[id]; - const newBound = this._computeEdgelessBound( - point.x, - point.y, - width, - height + if (isGroupLikeBlock(blockSnapshot.flavour)) { + Object.keys(blockSnapshot.props.childElementIds ?? {}).forEach( + childId => { + assertType>( + blockSnapshot.props.childElementIds + ); + + if (idRemap.has(childId)) { + const remappedId = idRemap.get(childId)!; + blockSnapshot.props.childElementIds[remappedId] = + blockSnapshot.props.childElementIds[childId]; + delete blockSnapshot.props.childElementIds[childId]; + } else { + delete blockSnapshot.props.childElementIds[childId]; + } + } + ); + } + + const slices = await this._dropToModel( + { + ...snapshot, + content: [blockSnapshot], + }, + surfaceChild ? surface.id : root.id ); - if (!newBound) return; - importToSurface(width, height, newBound); - return; + if (slices) { + idRemap.set(id, slices.content[0].id); + } + } else if (elemMap[id]) { + if (elemMap[id].children) { + const childJson = elemMap[id].children.json; + Object.keys(childJson).forEach(childId => { + if (idRemap.has(childId)) { + const remappedId = idRemap.get(childId)!; + childJson[remappedId] = childJson[childId]; + delete childJson[childId]; + } else { + delete childJson[childId]; + } + }); + } + const newId = surface.addElement(elemMap[id]); + idRemap.set(id, newId); } + }; + + for (const id of containerTree['root']) { + await addInDependencyOrder(id); } - const newNoteId = addNoteAtPoint( - this.std, - Point.from( - this._gfx.viewport.toModelCoordFromClientCoord([point.x, point.y]) - ), - { - scale: this.widget.noteScale.peek(), - } - ); - const newNoteBlock = this.widget.doc.getBlock(newNoteId)?.model as - | NoteBlockModel - | undefined; - if (!newNoteBlock) return; - - const bound = Bound.deserialize(newNoteBlock.xywh); - bound.h *= this.widget.noteScale.peek(); - bound.w *= this.widget.noteScale.peek(); - this.widget.doc.updateBlock(newNoteBlock, { - xywh: bound.serialize(), - edgeless: { - ...newNoteBlock.edgeless, - scale: this.widget.noteScale.peek(), - }, - }); - - this._dropToModel(snapshot, newNoteId).catch(console.error); + return idRemap; }; - private readonly _toSnapshot = (blocks: BlockComponent[]) => { + private readonly _getSnapshotRect = ( + snapshot: SliceSnapshot + ): Bound | null => { + let bound: Bound | null = null; + + const getBound = (block: BlockSnapshot) => { + if (block.flavour === 'affine:surface') { + if (block.props.elements) { + Object.values( + block.props.elements as Record + ).forEach(elem => { + if (elem.xywh) { + bound = bound + ? bound.unite(Bound.deserialize(elem.xywh)) + : Bound.deserialize(elem.xywh); + } + }); + } + + block.children.forEach(getBound); + } else if (block.props.xywh) { + bound = bound + ? bound.unite(Bound.deserialize(block.props.xywh as SerializedXYWH)) + : Bound.deserialize(block.props.xywh as SerializedXYWH); + } + }; + + snapshot.content.forEach(getBound); + + return bound; + }; + + /** + * Rewrite the xywh of the snapshot to make the top left corner of the snapshot align with the point + * @param snapshot + * @param point the point in model coordinate + * @returns + */ + private readonly _rewriteSnapshotXYWH = ( + snapshot: SliceSnapshot, + point: Point, + ignoreOriginalPos: boolean = false + ) => { + const rect = this._getSnapshotRect(snapshot); + + if (!rect) return; + const { x: modelX, y: modelY } = point; + + const rewrite = (block: BlockSnapshot) => { + if (block.flavour === 'affine:surface') { + if (block.props.elements) { + Object.values( + block.props.elements as Record + ).forEach(elem => { + if (elem.xywh) { + const elemBound = Bound.deserialize(elem.xywh); + + if (ignoreOriginalPos) { + elemBound.x = modelX; + elemBound.y = modelY; + elem.xywh = elemBound.serialize(); + } else { + elem.xywh = elemBound + .moveDelta(-rect.x + modelX, -rect.y + modelY) + .serialize(); + } + } + }); + } + block.children.forEach(rewrite); + } else if (block.props.xywh) { + const blockBound = Bound.deserialize( + block.props.xywh as SerializedXYWH + ); + + if ( + block.flavour === 'affine:attachment' || + block.flavour.startsWith('affine:embed-') + ) { + const style = (block.props.style ?? 'vertical') as EmbedCardStyle; + block.props.style = style; + + blockBound.w = EMBED_CARD_WIDTH[style]; + blockBound.h = EMBED_CARD_HEIGHT[style]; + } + + if (ignoreOriginalPos) { + blockBound.x = modelX; + blockBound.y = modelY; + block.props.xywh = blockBound.serialize(); + } else { + block.props.xywh = blockBound + .moveDelta(-rect.x + modelX, -rect.y + modelY) + .serialize(); + } + } + }; + + snapshot.content.forEach(rewrite); + }; + + private readonly _dropAsGfxBlock = ( + snapshot: SliceSnapshot, + point: Point + ) => { + const store = this.widget.std.store; + const schema = store.schema; + + point = Point.from( + this.gfx.viewport.toModelCoordFromClientCoord([point.x, point.y]) + ); + + // check if all blocks can be dropped as gfx block + const groupByParent = groupBy(snapshot.content, block => + schema.safeValidate(block.flavour, 'affine:surface') + ? 'affine:surface' + : schema.safeValidate(block.flavour, 'affine:page') + ? 'affine:page' + : // if the parent is not surface or page, it can't be dropped as gfx block + // mark it as empty + 'empty' + ); + + // empty means all blocks can be dropped as gfx block + if (!groupByParent.empty?.length) { + // drop as children of surface or page + + if (groupByParent['affine:surface']) { + const content = groupByParent['affine:surface']; + const surfaceSnapshot = { + ...snapshot, + content, + }; + + this._rewriteSnapshotXYWH(surfaceSnapshot, point, true); + this._dropToModel(surfaceSnapshot, this.gfx.surface!.id) + .then(slices => { + slices?.content.forEach((block, idx) => { + if ( + block.flavour === 'affine:attachment' || + block.flavour.startsWith('affine:embed-') + ) { + store.updateBlock(block as BlockModel, { + xywh: content[idx].props.xywh, + style: content[idx].props.style, + }); + } + }); + }) + .catch(console.error); + } + + if (groupByParent['affine:page']) { + const content = groupByParent['affine:page']; + const pageSnapshot = { + ...snapshot, + content, + }; + + this._rewriteSnapshotXYWH(pageSnapshot, point, true); + this._dropToModel(pageSnapshot, this.widget.doc.root!.id) + .then(slices => { + slices?.content.forEach((block, idx) => { + if ( + block.flavour === 'affine:attachment' || + block.flavour.startsWith('affine:embed-') + ) { + store.updateBlock(block as BlockModel, { + xywh: content[idx].props.xywh, + style: content[idx].props.style, + }); + } + }); + }) + .catch(console.error); + } + } else { + const content = snapshot.content.filter(block => + schema.safeValidate(block.flavour, 'affine:note') + ); + // create note to wrap the snapshot + const pos = this.gfx.viewport.toModelCoordFromClientCoord([ + point.x, + point.y, + ]); + const noteId = store.addBlock( + 'affine:note', + { + xywh: new Bound( + pos[0], + pos[1], + DEFAULT_NOTE_WIDTH, + DEFAULT_NOTE_HEIGHT + ).serialize(), + }, + this.widget.doc.root! + ); + + this._dropToModel( + { + ...snapshot, + content, + }, + noteId + ).catch(console.error); + } + }; + + private readonly _toSnapshot = ( + blocks: (BlockComponent | BlockModel)[], + selectedGfxElms?: string[] + ) => { const slice = Slice.fromModels( this.std.store, - blocks.map(block => block.model) + blocks.map(block => + toDraftModel(block instanceof BlockComponent ? block.model : block) + ) ); - const job = this._getJob(); + const job = this._getJob(selectedGfxElms); const snapshot = job.sliceToSnapshot(slice); if (!snapshot) return; @@ -597,35 +1152,58 @@ export class DragEventWatcher { } } - private _getJob() { + private _getJob(selectedIds?: string[]) { const std = this.std; - return std.getTransformer([ + const middlewares = [ newIdCrossDoc(std), reorderList(std), surfaceRefToEmbed(std), - ]); + ]; + + if (selectedIds) { + middlewares.push(gfxBlocksFilter(selectedIds, std)); + } + + return std.getTransformer(middlewares); } private _isDropOnCurrentEditor(std?: BlockStdScope) { return std === this.std; } + private _isUnderNoteBlock(model: BlockModel) { + let isUnderNote = false; + const store = this.std.store; + + { + let curModel = store.getParent(model); + + while (curModel) { + if (matchModels(curModel, [NoteBlockModel])) { + isUnderNote = true; + break; + } + + curModel = store.getParent(curModel)!; + } + } + + return isUnderNote; + } + private _makeDraggable(target: HTMLElement) { const std = this.std; return std.dnd.draggable({ element: target, - canDrag: () => { - const hoverBlock = this.widget.anchorBlockComponent.peek(); - return hoverBlock ? true : false; - }, + canDrag: () => (this.widget.anchorBlockId.peek() ? true : false), onDragStart: () => { this.widget.dragging = true; }, onDrop: () => { this._cleanup(); }, - setDragPreview: ({ source, container, setOffset }) => { + setDragPreview: ({ source, container }) => { if (!source.data?.bsEntity?.modelIds.length) { return; } @@ -634,15 +1212,13 @@ export class DragEventWatcher { source.data?.bsEntity?.modelIds, container ); - - const rect = container.getBoundingClientRect(); - setOffset({ x: rect.width / 2, y: rect.height / 2 }); }, setDragData: () => { - const { snapshot } = this._getSnapshotFromHoveredBlocks(); + const { fromMode, snapshot } = this._getDraggedSnapshot(); return { type: 'blocks', + fromMode, modelIds: snapshot ? extractIdsFromSnapshot(snapshot) : [], snapshot, }; @@ -651,7 +1227,19 @@ export class DragEventWatcher { } private _makeDropTarget(view: BlockComponent) { - if (view.model.role !== 'content' && view.model.role !== 'hub') { + const isUnderNote = this._isUnderNoteBlock(view.model); + + if ( + // affine:surface block can't be drop target in any modes + matchModels(view.model, [SurfaceBlockModel]) || + // in page mode, blocks other than root block can be drop target + (this.mode === 'page' && view.model.role === 'root') || + // in edgeless mode, only root and note block can be drop target + (this.mode === 'edgeless' && + !matchModels(view.model, [NoteBlockModel]) && + view.model.role !== 'root' && + !isUnderNote) + ) { return; } @@ -666,8 +1254,15 @@ export class DragEventWatcher { } >({ element: view, - getIsSticky: () => true, + getIsSticky: () => { + const result = this.mode === 'page' || isUnderNote; + return result; + }, canDrop: ({ source }) => { + /** + * general rules: + * 1. can't drop on the same block or its children + */ if (source.data.bsEntity?.type === 'blocks') { return ( source.data.from?.docId !== widget.doc.id || @@ -800,7 +1395,7 @@ export class DragEventWatcher { const disposables = widget.disposables; const scrollable = getScrollContainer(this.host); - if (scrollable) { + if (scrollable && this.mode === 'page') { disposables.add( std.dnd.autoScroll({ element: scrollable, diff --git a/blocksuite/affine/widget-drag-handle/src/watchers/edgeless-watcher.ts b/blocksuite/affine/widget-drag-handle/src/watchers/edgeless-watcher.ts index 12bb8be12d..20d806a4f3 100644 --- a/blocksuite/affine/widget-drag-handle/src/watchers/edgeless-watcher.ts +++ b/blocksuite/affine/widget-drag-handle/src/watchers/edgeless-watcher.ts @@ -2,10 +2,7 @@ import { EdgelessLegacySlotIdentifier, type SurfaceBlockComponent, } from '@blocksuite/affine-block-surface'; -import { - getSelectedRect, - isTopLevelBlock, -} from '@blocksuite/affine-shared/utils'; +import { getSelectedRect } from '@blocksuite/affine-shared/utils'; import { GfxControllerIdentifier, type GfxToolsFullOptionValue, @@ -16,19 +13,23 @@ import { effect } from '@preact/signals-core'; import { DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL, DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL, - DRAG_HANDLE_GRABBER_BORDER_RADIUS, - DRAG_HANDLE_GRABBER_WIDTH_HOVERED, HOVER_AREA_RECT_PADDING_TOP_LEVEL, } from '../config.js'; import type { AffineDragHandleWidget } from '../drag-handle.js'; +/** + * Used to control the drag handle visibility in edgeless mode + * + * 1. Show drag handle on every block and gfx element + * 2. Multiple selection is not supported + */ export class EdgelessWatcher { private readonly _handleEdgelessToolUpdated = ( newTool: GfxToolsFullOptionValue ) => { // @ts-expect-error FIXME: resolve after gfx tool refactor if (newTool.type === 'default') { - this.checkTopLevelBlockSelection(); + this.updateAnchorElement(); } else { this.widget.hide(); } @@ -52,17 +53,15 @@ export class EdgelessWatcher { this.widget.center = [...center]; } - if (this.widget.isTopLevelDragHandleVisible) { - this._showDragHandleOnTopLevelBlocks().catch(console.error); + if (this.widget.isGfxDragHandleVisible) { + this._showDragHandle().catch(console.error); this._updateDragHoverRectTopLevelBlock(); - } else { + } else if (this.widget.activeDragHandle) { this.widget.hide(); } }; - private readonly _showDragHandleOnTopLevelBlocks = async () => { - if (this.widget.mode === 'page') return; - + private readonly _showDragHandle = async () => { const surfaceModel = this.widget.doc.getBlockByFlavour('affine:surface'); const surface = this.widget.std.view.getBlock( surfaceModel[0]!.id @@ -75,84 +74,67 @@ export class EdgelessWatcher { const grabber = this.widget.dragHandleGrabber; if (!container || !grabber) return; - const area = this.hoverAreaTopLevelBlock; + const area = this.hoveredElemArea; if (!area) return; - const height = area.height; - - const posLeft = area.left; - - const posTop = (area.top += area.padding); - container.style.transition = 'none'; container.style.paddingTop = `0px`; container.style.paddingBottom = `0px`; - container.style.width = `${area.containerWidth}px`; - container.style.left = `${posLeft}px`; - container.style.top = `${posTop}px`; + container.style.left = `${area.left}px`; + container.style.top = `${area.top}px`; container.style.display = 'flex'; - container.style.height = `${height}px`; - - grabber.style.width = `${DRAG_HANDLE_GRABBER_WIDTH_HOVERED * this.widget.scale.peek()}px`; - grabber.style.borderRadius = `${ - DRAG_HANDLE_GRABBER_BORDER_RADIUS * this.widget.scale.peek() - }px`; this.widget.handleAnchorModelDisposables(); - this.widget.isTopLevelDragHandleVisible = true; + this.widget.activeDragHandle = 'gfx'; }; private readonly _updateDragHoverRectTopLevelBlock = () => { if (!this.widget.dragHoverRect) return; - this.widget.dragHoverRect = this.hoverAreaRectTopLevelBlock; + this.widget.dragHoverRect = this.hoveredElemAreaRect; }; - checkTopLevelBlockSelection = () => { - if (!this.widget.isConnected) return; + get gfx() { + return this.widget.std.get(GfxControllerIdentifier); + } + updateAnchorElement = () => { + if (!this.widget.isConnected) return; if (this.widget.doc.readonly || this.widget.mode === 'page') { this.widget.hide(); return; } - const { std } = this.widget; - const gfx = std.get(GfxControllerIdentifier); - const { selection } = gfx; + const { selection } = this.gfx; const editing = selection.editing; const selectedElements = selection.selectedElements; - if (editing || selectedElements.length !== 1) { + + if (editing || selectedElements.length !== 1 || this.widget.doc.readonly) { this.widget.hide(); return; } const selectedElement = selectedElements[0]; - if (!isTopLevelBlock(selectedElement)) { - this.widget.hide(); - return; - } this.widget.anchorBlockId.value = selectedElement.id; - this._showDragHandleOnTopLevelBlocks().catch(console.error); + this._showDragHandle().catch(console.error); }; - get hoverAreaRectTopLevelBlock() { - const area = this.hoverAreaTopLevelBlock; + get hoveredElemAreaRect() { + const area = this.hoveredElemArea; if (!area) return null; return new Rect(area.left, area.top, area.right, area.bottom); } - get hoverAreaTopLevelBlock() { + get hoveredElemArea() { const edgelessElement = this.widget.anchorEdgelessElement.peek(); if (!edgelessElement) return null; - const { std } = this.widget; - const gfx = std.get(GfxControllerIdentifier); - const { viewport } = gfx; + const { viewport } = this.gfx; const rect = getSelectedRect([edgelessElement]); let [left, top] = viewport.toViewCoord(rect.left, rect.top); const scale = this.widget.scale.peek(); @@ -186,6 +168,10 @@ export class EdgelessWatcher { constructor(readonly widget: AffineDragHandleWidget) {} watch() { + if (this.widget.mode === 'page') { + return; + } + const { disposables, std } = this.widget; const gfx = std.get(GfxControllerIdentifier); const { viewport, selection, tool } = gfx; @@ -197,7 +183,19 @@ export class EdgelessWatcher { disposables.add( selection.slots.updated.on(() => { - this.checkTopLevelBlockSelection(); + this.updateAnchorElement(); + }) + ); + + disposables.add( + edgelessSlots.readonlyUpdated.on(() => { + this.updateAnchorElement(); + }) + ); + + disposables.add( + edgelessSlots.elementResizeEnd.on(() => { + this.updateAnchorElement(); }) ); @@ -209,22 +207,10 @@ export class EdgelessWatcher { }) ); - disposables.add( - edgelessSlots.readonlyUpdated.on(() => { - this.checkTopLevelBlockSelection(); - }) - ); - disposables.add( edgelessSlots.elementResizeStart.on(() => { this.widget.hide(); }) ); - - disposables.add( - edgelessSlots.elementResizeEnd.on(() => { - this.checkTopLevelBlockSelection(); - }) - ); } } diff --git a/blocksuite/affine/widget-drag-handle/src/watchers/handle-event-watcher.ts b/blocksuite/affine/widget-drag-handle/src/watchers/handle-event-watcher.ts index d0bb58dee2..013ea64bc8 100644 --- a/blocksuite/affine/widget-drag-handle/src/watchers/handle-event-watcher.ts +++ b/blocksuite/affine/widget-drag-handle/src/watchers/handle-event-watcher.ts @@ -7,7 +7,7 @@ import type { AffineDragHandleWidget } from '../drag-handle.js'; export class HandleEventWatcher { private readonly _onDragHandlePointerDown = () => { - if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId) + if (!this.widget.isBlockDragHandleVisible || !this.widget.anchorBlockId) return; this.widget.dragHoverRect = this.widget.draggingAreaRect.value; @@ -18,7 +18,7 @@ export class HandleEventWatcher { const grabber = this.widget.dragHandleGrabber; if (!container || !grabber) return; - if (this.widget.isHoverDragHandleVisible && this.widget.anchorBlockId) { + if (this.widget.isBlockDragHandleVisible && this.widget.anchorBlockId) { const block = this.widget.anchorBlockComponent; if (!block) return; @@ -35,9 +35,9 @@ export class HandleEventWatcher { }px`; this.widget.isDragHandleHovered = true; - } else if (this.widget.isTopLevelDragHandleVisible) { + } else if (this.widget.isGfxDragHandleVisible) { this.widget.dragHoverRect = - this.widget.edgelessWatcher.hoverAreaRectTopLevelBlock; + this.widget.edgelessWatcher.hoveredElemAreaRect; this.widget.isDragHandleHovered = true; } }; @@ -46,7 +46,7 @@ export class HandleEventWatcher { this.widget.isDragHandleHovered = false; this.widget.dragHoverRect = null; - if (this.widget.isTopLevelDragHandleVisible) return; + if (this.widget.isGfxDragHandleVisible) return; if (this.widget.dragging) return; @@ -54,7 +54,7 @@ export class HandleEventWatcher { }; private readonly _onDragHandlePointerUp = () => { - if (!this.widget.isHoverDragHandleVisible) return; + if (!this.widget.isBlockDragHandleVisible) return; this.widget.dragHoverRect = null; }; diff --git a/blocksuite/affine/widget-drag-handle/src/watchers/pointer-event-watcher.ts b/blocksuite/affine/widget-drag-handle/src/watchers/pointer-event-watcher.ts index 69a232c311..423cf25890 100644 --- a/blocksuite/affine/widget-drag-handle/src/watchers/pointer-event-watcher.ts +++ b/blocksuite/affine/widget-drag-handle/src/watchers/pointer-event-watcher.ts @@ -29,6 +29,9 @@ import { updateDragHandleClassName, } from '../utils.js'; +/** + * Used to control the drag handle visibility in page mode + */ export class PointerEventWatcher { private get _gfx() { return this.widget.std.get(GfxControllerIdentifier); @@ -51,7 +54,7 @@ export class PointerEventWatcher { * Should clear selection if current block is the first selected block */ private readonly _clickHandler: UIEventHandler = ctx => { - if (!this.widget.isHoverDragHandleVisible) return; + if (!this.widget.isBlockDragHandleVisible) return; const state = ctx.get('pointerState'); const { target } = state.raw; @@ -152,7 +155,7 @@ export class PointerEventWatcher { * And update hover block id and path */ private readonly _pointerMoveOnBlock = (state: PointerEventState) => { - if (this.widget.isTopLevelDragHandleVisible) return; + if (this.widget.isGfxDragHandleVisible) return; const point = new Point(state.raw.x, state.raw.y); const closestBlock = getClosestBlockByPoint( @@ -182,7 +185,7 @@ export class PointerEventWatcher { this.widget.anchorBlockId.peek(), this._lastHoveredBlockId ) || - !this.widget.isHoverDragHandleVisible) && + !this.widget.isBlockDragHandleVisible) && !this.widget.isDragHandleHovered ) { this.showDragHandleOnHoverBlock(); @@ -224,7 +227,7 @@ export class PointerEventWatcher { this.widget.hide(); return; } - if (this.widget.isTopLevelDragHandleVisible) return; + if (this.widget.isGfxDragHandleVisible) return; const state = ctx.get('pointerState'); const { target } = state.raw; @@ -263,7 +266,9 @@ export class PointerEventWatcher { return true; } - this.widget.hide(); + if (this.widget.activeDragHandle) { + this.widget.hide(); + } return false; }, 1000 / 60 @@ -278,7 +283,7 @@ export class PointerEventWatcher { const grabber = this.widget.dragHandleGrabber; if (!container || !grabber) return; - this.widget.isHoverDragHandleVisible = true; + this.widget.activeDragHandle = 'block'; const draggingAreaRect = this.widget.draggingAreaRect.peek(); if (!draggingAreaRect) return; diff --git a/blocksuite/blocks/src/root-block/edgeless/services/template.ts b/blocksuite/blocks/src/root-block/edgeless/services/template.ts index 0e2858acc3..91acc93894 100644 --- a/blocksuite/blocks/src/root-block/edgeless/services/template.ts +++ b/blocksuite/blocks/src/root-block/edgeless/services/template.ts @@ -322,8 +322,9 @@ export class TemplateJob { ) { const schema = this.model.doc.workspace.schema.flavourSchemaMap.get('affine:surface'); - const surfaceTransformer = - schema?.transformer?.() as SurfaceBlockTransformer; + const surfaceTransformer = schema?.transformer?.( + new Map() + ) as SurfaceBlockTransformer; this.model.doc.transact(() => { const defered: [string, Record][] = []; diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts b/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts index b7d058862c..6518a0448a 100644 --- a/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts +++ b/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts @@ -433,6 +433,10 @@ export class SurfaceBlockModel extends BlockModel { this._watchGroupRelationChange(); } + getConstructor(type: string) { + return this._elementCtorMap[type]; + } + addElement>( props: Partial & { type: string } ) { diff --git a/blocksuite/framework/store/src/__tests__/transformer.unit.spec.ts b/blocksuite/framework/store/src/__tests__/transformer.unit.spec.ts index 33af32fdcc..05cc675ab7 100644 --- a/blocksuite/framework/store/src/__tests__/transformer.unit.spec.ts +++ b/blocksuite/framework/store/src/__tests__/transformer.unit.spec.ts @@ -50,7 +50,7 @@ function createTestOptions() { return { id: 'test-collection', idGenerator, schema }; } -const transformer = new BaseBlockTransformer(); +const transformer = new BaseBlockTransformer(new Map()); const blobCRUD = new MemoryBlobCRUD(); const assets = new AssetsManager({ blob: blobCRUD }); diff --git a/blocksuite/framework/store/src/model/block/zod.ts b/blocksuite/framework/store/src/model/block/zod.ts index 22fbd45597..2458b65295 100644 --- a/blocksuite/framework/store/src/model/block/zod.ts +++ b/blocksuite/framework/store/src/model/block/zod.ts @@ -40,7 +40,7 @@ export const BlockSchema = z.object({ }), transformer: z .function() - .args() + .args(z.custom>()) .returns(z.custom()) .optional(), }); @@ -69,14 +69,14 @@ export function defineBlockSchema< metadata: Metadata; props?: (internalPrimitives: InternalPrimitives) => Props; toModel?: () => Model; - transformer?: () => Transformer; + transformer?: (transformerConfig: Map) => Transformer; }): { version: number; model: { props: PropsGetter; flavour: Flavour; } & Metadata; - transformer?: () => Transformer; + transformer?: (transformerConfig: Map) => Transformer; }; export function defineBlockSchema({ @@ -96,7 +96,9 @@ export function defineBlockSchema({ }; props?: (internalPrimitives: InternalPrimitives) => Record; toModel?: () => BlockModel; - transformer?: () => BaseBlockTransformer; + transformer?: ( + transformerConfig: Map + ) => BaseBlockTransformer; }): BlockSchemaType { const schema = { version: metadata.version, diff --git a/blocksuite/framework/store/src/transformer/base.ts b/blocksuite/framework/store/src/transformer/base.ts index 2513e70c3b..d6b9590838 100644 --- a/blocksuite/framework/store/src/transformer/base.ts +++ b/blocksuite/framework/store/src/transformer/base.ts @@ -51,6 +51,8 @@ export class BaseBlockTransformer { ); } + constructor(public readonly transformerConfigs: Map) {} + fromSnapshot({ json, }: FromSnapshotPayload): Promise> | SnapshotNode { diff --git a/blocksuite/framework/store/src/transformer/middleware.ts b/blocksuite/framework/store/src/transformer/middleware.ts index 9eb5bb4329..3fd51d8f2f 100644 --- a/blocksuite/framework/store/src/transformer/middleware.ts +++ b/blocksuite/framework/store/src/transformer/middleware.ts @@ -82,7 +82,8 @@ type TransformerMiddlewareOptions = { assetsManager: AssetsManager; slots: TransformerSlots; docCRUD: DocCRUD; - adapterConfigs: Map; + adapterConfigs: Map; + transformerConfigs: Map; }; export type TransformerMiddleware = ( diff --git a/blocksuite/framework/store/src/transformer/transformer.ts b/blocksuite/framework/store/src/transformer/transformer.ts index 91a7e1d0fc..4aa233070c 100644 --- a/blocksuite/framework/store/src/transformer/transformer.ts +++ b/blocksuite/framework/store/src/transformer/transformer.ts @@ -56,6 +56,8 @@ const BATCH_SIZE = 100; export class Transformer { private readonly _adapterConfigs = new Map(); + private readonly _transformerConfigs = new Map(); + private readonly _assetsManager: AssetsManager; private readonly _schema: Schema; @@ -72,6 +74,11 @@ export class Transformer { blockToSnapshot = (model: DraftModel): BlockSnapshot | undefined => { try { const snapshot = this._blockToSnapshot(model); + + if (!snapshot) { + return; + } + BlockSnapshotSchema.parse(snapshot); return snapshot; @@ -354,24 +361,28 @@ export class Transformer { docCRUD: this._docCRUD, assetsManager: this._assetsManager, adapterConfigs: this._adapterConfigs, + transformerConfigs: this._transformerConfigs, }); }); } - private _blockToSnapshot(model: DraftModel): BlockSnapshot { + private _blockToSnapshot(model: DraftModel): BlockSnapshot | null { this._slots.beforeExport.emit({ type: 'block', model, }); + const schema = this._getSchema(model.flavour); const transformer = this._getTransformer(schema); const snapshotLeaf = transformer.toSnapshot({ model, assets: this._assetsManager, }); - const children = model.children.map(child => { - return this._blockToSnapshot(child); - }); + const children = model.children + .map(child => { + return this._blockToSnapshot(child); + }) + .filter(Boolean) as BlockSnapshot[]; const snapshot: BlockSnapshot = { type: 'block', ...snapshotLeaf, @@ -489,7 +500,10 @@ export class Transformer { } private _getTransformer(schema: BlockSchemaType) { - return schema.transformer?.() ?? new BaseBlockTransformer(); + return ( + schema.transformer?.(this._transformerConfigs) ?? + new BaseBlockTransformer(this._transformerConfigs) + ); } private async _insertBlockTree( diff --git a/blocksuite/tests-legacy/edgeless/edgeless-text.spec.ts b/blocksuite/tests-legacy/edgeless/edgeless-text.spec.ts index 8bebce5e6c..5517e70c38 100644 --- a/blocksuite/tests-legacy/edgeless/edgeless-text.spec.ts +++ b/blocksuite/tests-legacy/edgeless/edgeless-text.spec.ts @@ -204,13 +204,13 @@ test.describe('edgeless text block', () => { )!; return container.getBoundingClientRect(); }); - const model = await page.evaluate(() => { + const modelXYWH = await page.evaluate(() => { const block = window.host.view.getBlock( '4' ) as EdgelessTextBlockComponent; - return block.model; + return block.model.xywh; }); - const bound = Bound.deserialize(model.xywh); + const bound = Bound.deserialize(modelXYWH); expect(rect.width).toBeCloseTo(bound.w); expect(rect.height).toBeCloseTo(bound.h); });