feat: edgeless dnd (#9988)

### Changed
- Support edgelss dnd
- Simplify the drag-handle state
This commit is contained in:
doouding
2025-02-12 12:37:06 +00:00
parent 8129434a2e
commit f89fcf82f8
22 changed files with 1041 additions and 350 deletions
@@ -13,25 +13,22 @@ import {
import { Peekable } from '@blocksuite/affine-components/peek'; import { Peekable } from '@blocksuite/affine-components/peek';
import { import {
FrameBlockModel, FrameBlockModel,
GroupElementModel, RootBlockModel,
type SurfaceRefBlockModel, type SurfaceRefBlockModel,
} from '@blocksuite/affine-model'; } from '@blocksuite/affine-model';
import { import {
DocModeProvider, DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
EditPropsStore, EditPropsStore,
GeneralSettingSchema,
ThemeProvider, ThemeProvider,
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { import {
matchModels,
requestConnectedFrame, requestConnectedFrame,
SpecProvider, SpecProvider,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
import { import {
BlockComponent, BlockComponent,
BlockSelection, BlockSelection,
BlockServiceWatcher,
BlockStdScope, BlockStdScope,
type EditorHost, type EditorHost,
LifeCycleWatcher, LifeCycleWatcher,
@@ -40,8 +37,8 @@ import {
import { import {
GfxBlockElementModel, GfxBlockElementModel,
GfxControllerIdentifier, GfxControllerIdentifier,
GfxExtension,
type GfxModel, type GfxModel,
GfxPrimitiveElementModel,
} from '@blocksuite/block-std/gfx'; } from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { import {
@@ -52,12 +49,10 @@ import {
type SerializedXYWH, type SerializedXYWH,
} from '@blocksuite/global/utils'; } from '@blocksuite/global/utils';
import type { BaseSelection, Store } from '@blocksuite/store'; import type { BaseSelection, Store } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { css, html, nothing, type TemplateResult } from 'lit'; import { css, html, nothing, type TemplateResult } 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 { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessPreviewer } from './types.js';
import { noContentPlaceholder } from './utils.js'; import { noContentPlaceholder } from './utils.js';
const REF_LABEL_ICON = { const REF_LABEL_ICON = {
@@ -427,48 +422,26 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
private _initSpec() { private _initSpec() {
const refreshViewport = this._refreshViewport.bind(this); const refreshViewport = this._refreshViewport.bind(this);
// oxlint-disable-next-line @typescript-eslint/no-this-alias class SurfaceRefViewportInitializer extends LifeCycleWatcher {
const self = this; static override readonly key = 'surfaceRefViewportInitializer';
const editorSetting =
this.std.getOptional(EditorSettingProvider) ??
signal(GeneralSettingSchema.parse({}));
class PageViewWatcher extends BlockServiceWatcher {
static override readonly flavour = 'affine:page';
override mounted() { override mounted() {
this.blockService.disposables.add( const disposable = this.std.view.viewUpdated.on(payload => {
this.blockService.specSlots.viewConnected.once(({ component }) => { if (
const edgelessBlock = component as BlockComponent & payload.type === 'add' &&
EdgelessPreviewer; matchModels(payload.view.model, [RootBlockModel])
) {
edgelessBlock.editorViewportSelector = 'ref-viewport'; disposable.dispose();
refreshViewport(); queueMicrotask(() => refreshViewport());
const gfx = edgelessBlock.std.get(GfxControllerIdentifier); const gfx = this.std.get(GfxControllerIdentifier);
gfx.viewport.sizeUpdated.once(() => { gfx.viewport.sizeUpdated.on(() => {
refreshViewport(); refreshViewport();
}); });
}) }
); });
} }
} }
this._previewSpec.extend([SurfaceRefViewportInitializer]);
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),
]);
const referenceId = this.model.reference; const referenceId = this.model.reference;
const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => { const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => {
@@ -501,7 +474,7 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
refreshViewport(); refreshViewport();
}) })
); );
} else if (referenceElement instanceof GroupElementModel) { } else if (referenceElement instanceof GfxPrimitiveElementModel) {
_disposable.add( _disposable.add(
surfaceModel.elementUpdated.on(({ id, oldValues }) => { surfaceModel.elementUpdated.on(({ id, oldValues }) => {
if ( if (
@@ -513,8 +486,6 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
} }
}) })
); );
} else {
console.warn('Unsupported reference element type');
} }
} }
@@ -31,7 +31,8 @@ export const SurfaceBlockSchema = defineBlockSchema({
'affine:edgeless-text', 'affine:edgeless-text',
], ],
}, },
transformer: () => new SurfaceBlockTransformer(), transformer: transformerConfigs =>
new SurfaceBlockTransformer(transformerConfigs),
toModel: () => new SurfaceBlockModel(), toModel: () => new SurfaceBlockModel(),
}); });
@@ -92,9 +92,18 @@ export class SurfaceBlockTransformer extends BaseBlockTransformer<SurfaceBlockPr
const snapshot = super.toSnapshot(payload); const snapshot = super.toSnapshot(payload);
const elementsValue = payload.model.elements.getValue(); const elementsValue = payload.model.elements.getValue();
const value: Record<string, unknown> = {}; const value: Record<string, unknown> = {};
/**
* When the selectedElements is defined, only the selected elements will be serialized.
*/
const selectedElements = this.transformerConfigs.get(
'selectedElements'
) as Set<string>;
if (elementsValue) { if (elementsValue) {
elementsValue.forEach((element, key) => { elementsValue.forEach((element, key) => {
value[key] = this._elementToJSON(element as Y.Map<unknown>); if (selectedElements?.has(key) || !selectedElements) {
value[key] = this._elementToJSON(element as Y.Map<unknown>);
}
}); });
} }
snapshot.props = { snapshot.props = {
@@ -81,7 +81,8 @@ export const AttachmentBlockSchema = defineBlockSchema({
'affine:list', 'affine:list',
], ],
}, },
transformer: () => new AttachmentBlockTransformer(), transformer: transformerConfigs =>
new AttachmentBlockTransformer(transformerConfigs),
toModel: () => new AttachmentBlockModel(), toModel: () => new AttachmentBlockModel(),
}); });
@@ -35,7 +35,8 @@ export const ImageBlockSchema = defineBlockSchema({
version: 1, version: 1,
role: 'content', role: 'content',
}, },
transformer: () => new ImageBlockTransformer(), transformer: transformerConfigs =>
new ImageBlockTransformer(transformerConfigs),
toModel: () => new ImageBlockModel(), toModel: () => new ImageBlockModel(),
}); });
@@ -4,10 +4,9 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { import {
isInsideEdgelessEditor, isInsideEdgelessEditor,
isInsidePageEditor, isInsidePageEditor,
isTopLevelBlock,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
import { type BlockComponent, WidgetComponent } from '@blocksuite/block-std'; 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 { import {
DisposableGroup, DisposableGroup,
type IVec, type IVec,
@@ -15,8 +14,9 @@ import {
type Rect, type Rect,
} from '@blocksuite/global/utils'; } from '@blocksuite/global/utils';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; 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 { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js'; import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js';
@@ -53,12 +53,13 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
this.dragHoverRect = null; this.dragHoverRect = null;
this.anchorBlockId.value = null; this.anchorBlockId.value = null;
this.isDragHandleHovered = false; this.isDragHandleHovered = false;
this.isHoverDragHandleVisible = false;
this.isTopLevelDragHandleVisible = false;
this.pointerEventWatcher.reset(); this.pointerEventWatcher.reset();
}; };
@state()
accessor activeDragHandle: 'block' | 'gfx' | null = null;
anchorBlockId = signal<string | null>(null); anchorBlockId = signal<string | null>(null);
anchorBlockComponent = computed<BlockComponent | null>(() => { anchorBlockComponent = computed<BlockComponent | null>(() => {
@@ -67,16 +68,14 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
return this.std.view.getBlock(this.anchorBlockId.value); return this.std.view.getBlock(this.anchorBlockId.value);
}); });
anchorEdgelessElement: ReadonlySignal<GfxBlockElementModel | null> = computed( anchorEdgelessElement: ReadonlySignal<GfxModel | null> = computed(() => {
() => { if (!this.anchorBlockId.value) return null;
if (!this.anchorBlockId.value) return null; if (this.mode === 'page') return null;
if (this.mode === 'page') return null;
const crud = this.std.get(EdgelessCRUDIdentifier); const crud = this.std.get(EdgelessCRUDIdentifier);
const edgelessElement = crud.getElementById(this.anchorBlockId.value); const edgelessElement = crud.getElementById(this.anchorBlockId.value);
return isTopLevelBlock(edgelessElement) ? edgelessElement : null; return edgelessElement;
} });
);
// Single block: drag handle should show on the vertical middle of the first line of element // Single block: drag handle should show on the vertical middle of the first line of element
center: IVec = [0, 0]; center: IVec = [0, 0];
@@ -115,15 +114,18 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
if (this.dragging && !force) return; if (this.dragging && !force) return;
updateDragHandleClassName(); updateDragHandleClassName();
this.isHoverDragHandleVisible = false;
this.isTopLevelDragHandleVisible = false;
this.isDragHandleHovered = false; this.isDragHandleHovered = false;
this.anchorBlockId.value = null; this.anchorBlockId.value = null;
this.activeDragHandle = null;
if (this.dragHandleContainer) { if (this.dragHandleContainer) {
this.dragHandleContainer.removeAttribute('style');
this.dragHandleContainer.style.display = 'none'; this.dragHandleContainer.style.display = 'none';
} }
if (this.dragHandleGrabber) {
this.dragHandleGrabber.removeAttribute('style');
}
if (force) { if (force) {
this._reset(); this._reset();
@@ -132,9 +134,13 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
isDragHandleHovered = false; isDragHandleHovered = false;
isHoverDragHandleVisible = false; get isBlockDragHandleVisible() {
return this.activeDragHandle === 'block';
}
isTopLevelDragHandleVisible = false; get isGfxDragHandleVisible() {
return this.activeDragHandle === 'gfx';
}
noteScale = signal(1); noteScale = signal(1);
@@ -190,7 +196,7 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
override render() { override render() {
const hoverRectStyle = styleMap( const hoverRectStyle = styleMap(
this.dragHoverRect this.dragHoverRect && this.activeDragHandle
? { ? {
width: `${this.dragHoverRect.width}px`, width: `${this.dragHoverRect.width}px`,
height: `${this.dragHoverRect.height}px`, height: `${this.dragHoverRect.height}px`,
@@ -201,11 +207,27 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
display: 'none', display: 'none',
} }
); );
const isGfx = this.activeDragHandle === 'gfx';
const classes = {
'affine-drag-handle-grabber': true,
dots: isGfx ? true : false,
};
return html` return html`
<div class="affine-drag-handle-widget"> <div class="affine-drag-handle-widget">
<div class="affine-drag-handle-container" draggable="true"> <div class="affine-drag-handle-container">
<div class="affine-drag-handle-grabber"></div> <div class=${classMap(classes)}>
${isGfx
? html`
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
`
: nothing}
</div>
</div> </div>
<div class="affine-drag-hover-rect" style=${hoverRectStyle}></div> <div class="affine-drag-hover-rect" style=${hoverRectStyle}></div>
</div> </div>
@@ -15,7 +15,7 @@ import {
export class RectHelper { export class RectHelper {
private readonly _getHoveredBlocks = (): BlockComponent[] => { private readonly _getHoveredBlocks = (): BlockComponent[] => {
if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId) if (!this.widget.isBlockDragHandleVisible || !this.widget.anchorBlockId)
return []; return [];
const hoverBlock = this.widget.anchorBlockComponent.peek(); const hoverBlock = this.widget.anchorBlockComponent.peek();
@@ -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<string>();
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;
}
});
};
};
@@ -1,3 +1,4 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit'; import { css } from 'lit';
import { DRAG_HANDLE_CONTAINER_WIDTH } from './config.js'; import { DRAG_HANDLE_CONTAINER_WIDTH } from './config.js';
@@ -35,6 +36,32 @@ export const styles = css`
transition: width 0.25s ease; 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 { @media print {
.affine-drag-handle-widget { .affine-drag-handle-widget {
display: none; display: none;
@@ -151,7 +151,7 @@ export const isOutOfNoteBlock = (
}; };
export const getParentNoteBlock = (blockComponent: BlockComponent) => { export const getParentNoteBlock = (blockComponent: BlockComponent) => {
return blockComponent.closest('affine-note') ?? null; return blockComponent.closest('affine-note, affine-edgeless-note') ?? null;
}; };
export const getClosestNoteBlock = ( export const getClosestNoteBlock = (
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,7 @@ import {
EdgelessLegacySlotIdentifier, EdgelessLegacySlotIdentifier,
type SurfaceBlockComponent, type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface'; } from '@blocksuite/affine-block-surface';
import { import { getSelectedRect } from '@blocksuite/affine-shared/utils';
getSelectedRect,
isTopLevelBlock,
} from '@blocksuite/affine-shared/utils';
import { import {
GfxControllerIdentifier, GfxControllerIdentifier,
type GfxToolsFullOptionValue, type GfxToolsFullOptionValue,
@@ -16,19 +13,23 @@ import { effect } from '@preact/signals-core';
import { import {
DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL, DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL,
DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL, DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_WIDTH_HOVERED,
HOVER_AREA_RECT_PADDING_TOP_LEVEL, HOVER_AREA_RECT_PADDING_TOP_LEVEL,
} from '../config.js'; } from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.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 { 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 FIXME: resolve after gfx tool refactor
if (newTool.type === 'default') { if (newTool.type === 'default') {
this.checkTopLevelBlockSelection(); this.updateAnchorElement();
} else { } else {
this.widget.hide(); this.widget.hide();
} }
@@ -52,17 +53,15 @@ export class EdgelessWatcher {
this.widget.center = [...center]; this.widget.center = [...center];
} }
if (this.widget.isTopLevelDragHandleVisible) { if (this.widget.isGfxDragHandleVisible) {
this._showDragHandleOnTopLevelBlocks().catch(console.error); this._showDragHandle().catch(console.error);
this._updateDragHoverRectTopLevelBlock(); this._updateDragHoverRectTopLevelBlock();
} else { } else if (this.widget.activeDragHandle) {
this.widget.hide(); this.widget.hide();
} }
}; };
private readonly _showDragHandleOnTopLevelBlocks = async () => { private readonly _showDragHandle = async () => {
if (this.widget.mode === 'page') return;
const surfaceModel = this.widget.doc.getBlockByFlavour('affine:surface'); const surfaceModel = this.widget.doc.getBlockByFlavour('affine:surface');
const surface = this.widget.std.view.getBlock( const surface = this.widget.std.view.getBlock(
surfaceModel[0]!.id surfaceModel[0]!.id
@@ -75,84 +74,67 @@ export class EdgelessWatcher {
const grabber = this.widget.dragHandleGrabber; const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return; if (!container || !grabber) return;
const area = this.hoverAreaTopLevelBlock; const area = this.hoveredElemArea;
if (!area) return; if (!area) return;
const height = area.height;
const posLeft = area.left;
const posTop = (area.top += area.padding);
container.style.transition = 'none'; container.style.transition = 'none';
container.style.paddingTop = `0px`; container.style.paddingTop = `0px`;
container.style.paddingBottom = `0px`; container.style.paddingBottom = `0px`;
container.style.width = `${area.containerWidth}px`; container.style.left = `${area.left}px`;
container.style.left = `${posLeft}px`; container.style.top = `${area.top}px`;
container.style.top = `${posTop}px`;
container.style.display = 'flex'; 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.handleAnchorModelDisposables();
this.widget.isTopLevelDragHandleVisible = true; this.widget.activeDragHandle = 'gfx';
}; };
private readonly _updateDragHoverRectTopLevelBlock = () => { private readonly _updateDragHoverRectTopLevelBlock = () => {
if (!this.widget.dragHoverRect) return; if (!this.widget.dragHoverRect) return;
this.widget.dragHoverRect = this.hoverAreaRectTopLevelBlock; this.widget.dragHoverRect = this.hoveredElemAreaRect;
}; };
checkTopLevelBlockSelection = () => { get gfx() {
if (!this.widget.isConnected) return; return this.widget.std.get(GfxControllerIdentifier);
}
updateAnchorElement = () => {
if (!this.widget.isConnected) return;
if (this.widget.doc.readonly || this.widget.mode === 'page') { if (this.widget.doc.readonly || this.widget.mode === 'page') {
this.widget.hide(); this.widget.hide();
return; return;
} }
const { std } = this.widget; const { selection } = this.gfx;
const gfx = std.get(GfxControllerIdentifier);
const { selection } = gfx;
const editing = selection.editing; const editing = selection.editing;
const selectedElements = selection.selectedElements; const selectedElements = selection.selectedElements;
if (editing || selectedElements.length !== 1) {
if (editing || selectedElements.length !== 1 || this.widget.doc.readonly) {
this.widget.hide(); this.widget.hide();
return; return;
} }
const selectedElement = selectedElements[0]; const selectedElement = selectedElements[0];
if (!isTopLevelBlock(selectedElement)) {
this.widget.hide();
return;
}
this.widget.anchorBlockId.value = selectedElement.id; this.widget.anchorBlockId.value = selectedElement.id;
this._showDragHandleOnTopLevelBlocks().catch(console.error); this._showDragHandle().catch(console.error);
}; };
get hoverAreaRectTopLevelBlock() { get hoveredElemAreaRect() {
const area = this.hoverAreaTopLevelBlock; const area = this.hoveredElemArea;
if (!area) return null; if (!area) return null;
return new Rect(area.left, area.top, area.right, area.bottom); return new Rect(area.left, area.top, area.right, area.bottom);
} }
get hoverAreaTopLevelBlock() { get hoveredElemArea() {
const edgelessElement = this.widget.anchorEdgelessElement.peek(); const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null; if (!edgelessElement) return null;
const { std } = this.widget; const { viewport } = this.gfx;
const gfx = std.get(GfxControllerIdentifier);
const { viewport } = gfx;
const rect = getSelectedRect([edgelessElement]); const rect = getSelectedRect([edgelessElement]);
let [left, top] = viewport.toViewCoord(rect.left, rect.top); let [left, top] = viewport.toViewCoord(rect.left, rect.top);
const scale = this.widget.scale.peek(); const scale = this.widget.scale.peek();
@@ -186,6 +168,10 @@ export class EdgelessWatcher {
constructor(readonly widget: AffineDragHandleWidget) {} constructor(readonly widget: AffineDragHandleWidget) {}
watch() { watch() {
if (this.widget.mode === 'page') {
return;
}
const { disposables, std } = this.widget; const { disposables, std } = this.widget;
const gfx = std.get(GfxControllerIdentifier); const gfx = std.get(GfxControllerIdentifier);
const { viewport, selection, tool } = gfx; const { viewport, selection, tool } = gfx;
@@ -197,7 +183,19 @@ export class EdgelessWatcher {
disposables.add( disposables.add(
selection.slots.updated.on(() => { 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( disposables.add(
edgelessSlots.elementResizeStart.on(() => { edgelessSlots.elementResizeStart.on(() => {
this.widget.hide(); this.widget.hide();
}) })
); );
disposables.add(
edgelessSlots.elementResizeEnd.on(() => {
this.checkTopLevelBlockSelection();
})
);
} }
} }
@@ -7,7 +7,7 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
export class HandleEventWatcher { export class HandleEventWatcher {
private readonly _onDragHandlePointerDown = () => { private readonly _onDragHandlePointerDown = () => {
if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId) if (!this.widget.isBlockDragHandleVisible || !this.widget.anchorBlockId)
return; return;
this.widget.dragHoverRect = this.widget.draggingAreaRect.value; this.widget.dragHoverRect = this.widget.draggingAreaRect.value;
@@ -18,7 +18,7 @@ export class HandleEventWatcher {
const grabber = this.widget.dragHandleGrabber; const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return; if (!container || !grabber) return;
if (this.widget.isHoverDragHandleVisible && this.widget.anchorBlockId) { if (this.widget.isBlockDragHandleVisible && this.widget.anchorBlockId) {
const block = this.widget.anchorBlockComponent; const block = this.widget.anchorBlockComponent;
if (!block) return; if (!block) return;
@@ -35,9 +35,9 @@ export class HandleEventWatcher {
}px`; }px`;
this.widget.isDragHandleHovered = true; this.widget.isDragHandleHovered = true;
} else if (this.widget.isTopLevelDragHandleVisible) { } else if (this.widget.isGfxDragHandleVisible) {
this.widget.dragHoverRect = this.widget.dragHoverRect =
this.widget.edgelessWatcher.hoverAreaRectTopLevelBlock; this.widget.edgelessWatcher.hoveredElemAreaRect;
this.widget.isDragHandleHovered = true; this.widget.isDragHandleHovered = true;
} }
}; };
@@ -46,7 +46,7 @@ export class HandleEventWatcher {
this.widget.isDragHandleHovered = false; this.widget.isDragHandleHovered = false;
this.widget.dragHoverRect = null; this.widget.dragHoverRect = null;
if (this.widget.isTopLevelDragHandleVisible) return; if (this.widget.isGfxDragHandleVisible) return;
if (this.widget.dragging) return; if (this.widget.dragging) return;
@@ -54,7 +54,7 @@ export class HandleEventWatcher {
}; };
private readonly _onDragHandlePointerUp = () => { private readonly _onDragHandlePointerUp = () => {
if (!this.widget.isHoverDragHandleVisible) return; if (!this.widget.isBlockDragHandleVisible) return;
this.widget.dragHoverRect = null; this.widget.dragHoverRect = null;
}; };
@@ -29,6 +29,9 @@ import {
updateDragHandleClassName, updateDragHandleClassName,
} from '../utils.js'; } from '../utils.js';
/**
* Used to control the drag handle visibility in page mode
*/
export class PointerEventWatcher { export class PointerEventWatcher {
private get _gfx() { private get _gfx() {
return this.widget.std.get(GfxControllerIdentifier); return this.widget.std.get(GfxControllerIdentifier);
@@ -51,7 +54,7 @@ export class PointerEventWatcher {
* Should clear selection if current block is the first selected block * Should clear selection if current block is the first selected block
*/ */
private readonly _clickHandler: UIEventHandler = ctx => { private readonly _clickHandler: UIEventHandler = ctx => {
if (!this.widget.isHoverDragHandleVisible) return; if (!this.widget.isBlockDragHandleVisible) return;
const state = ctx.get('pointerState'); const state = ctx.get('pointerState');
const { target } = state.raw; const { target } = state.raw;
@@ -152,7 +155,7 @@ export class PointerEventWatcher {
* And update hover block id and path * And update hover block id and path
*/ */
private readonly _pointerMoveOnBlock = (state: PointerEventState) => { 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 point = new Point(state.raw.x, state.raw.y);
const closestBlock = getClosestBlockByPoint( const closestBlock = getClosestBlockByPoint(
@@ -182,7 +185,7 @@ export class PointerEventWatcher {
this.widget.anchorBlockId.peek(), this.widget.anchorBlockId.peek(),
this._lastHoveredBlockId this._lastHoveredBlockId
) || ) ||
!this.widget.isHoverDragHandleVisible) && !this.widget.isBlockDragHandleVisible) &&
!this.widget.isDragHandleHovered !this.widget.isDragHandleHovered
) { ) {
this.showDragHandleOnHoverBlock(); this.showDragHandleOnHoverBlock();
@@ -224,7 +227,7 @@ export class PointerEventWatcher {
this.widget.hide(); this.widget.hide();
return; return;
} }
if (this.widget.isTopLevelDragHandleVisible) return; if (this.widget.isGfxDragHandleVisible) return;
const state = ctx.get('pointerState'); const state = ctx.get('pointerState');
const { target } = state.raw; const { target } = state.raw;
@@ -263,7 +266,9 @@ export class PointerEventWatcher {
return true; return true;
} }
this.widget.hide(); if (this.widget.activeDragHandle) {
this.widget.hide();
}
return false; return false;
}, },
1000 / 60 1000 / 60
@@ -278,7 +283,7 @@ export class PointerEventWatcher {
const grabber = this.widget.dragHandleGrabber; const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return; if (!container || !grabber) return;
this.widget.isHoverDragHandleVisible = true; this.widget.activeDragHandle = 'block';
const draggingAreaRect = this.widget.draggingAreaRect.peek(); const draggingAreaRect = this.widget.draggingAreaRect.peek();
if (!draggingAreaRect) return; if (!draggingAreaRect) return;
@@ -322,8 +322,9 @@ export class TemplateJob {
) { ) {
const schema = const schema =
this.model.doc.workspace.schema.flavourSchemaMap.get('affine:surface'); this.model.doc.workspace.schema.flavourSchemaMap.get('affine:surface');
const surfaceTransformer = const surfaceTransformer = schema?.transformer?.(
schema?.transformer?.() as SurfaceBlockTransformer; new Map()
) as SurfaceBlockTransformer;
this.model.doc.transact(() => { this.model.doc.transact(() => {
const defered: [string, Record<string, unknown>][] = []; const defered: [string, Record<string, unknown>][] = [];
@@ -433,6 +433,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
this._watchGroupRelationChange(); this._watchGroupRelationChange();
} }
getConstructor(type: string) {
return this._elementCtorMap[type];
}
addElement<T extends object = Record<string, unknown>>( addElement<T extends object = Record<string, unknown>>(
props: Partial<T> & { type: string } props: Partial<T> & { type: string }
) { ) {
@@ -50,7 +50,7 @@ function createTestOptions() {
return { id: 'test-collection', idGenerator, schema }; return { id: 'test-collection', idGenerator, schema };
} }
const transformer = new BaseBlockTransformer(); const transformer = new BaseBlockTransformer(new Map());
const blobCRUD = new MemoryBlobCRUD(); const blobCRUD = new MemoryBlobCRUD();
const assets = new AssetsManager({ blob: blobCRUD }); const assets = new AssetsManager({ blob: blobCRUD });
@@ -40,7 +40,7 @@ export const BlockSchema = z.object({
}), }),
transformer: z transformer: z
.function() .function()
.args() .args(z.custom<Map<string, unknown>>())
.returns(z.custom<BaseBlockTransformer>()) .returns(z.custom<BaseBlockTransformer>())
.optional(), .optional(),
}); });
@@ -69,14 +69,14 @@ export function defineBlockSchema<
metadata: Metadata; metadata: Metadata;
props?: (internalPrimitives: InternalPrimitives) => Props; props?: (internalPrimitives: InternalPrimitives) => Props;
toModel?: () => Model; toModel?: () => Model;
transformer?: () => Transformer; transformer?: (transformerConfig: Map<string, unknown>) => Transformer;
}): { }): {
version: number; version: number;
model: { model: {
props: PropsGetter<Props>; props: PropsGetter<Props>;
flavour: Flavour; flavour: Flavour;
} & Metadata; } & Metadata;
transformer?: () => Transformer; transformer?: (transformerConfig: Map<string, unknown>) => Transformer;
}; };
export function defineBlockSchema({ export function defineBlockSchema({
@@ -96,7 +96,9 @@ export function defineBlockSchema({
}; };
props?: (internalPrimitives: InternalPrimitives) => Record<string, unknown>; props?: (internalPrimitives: InternalPrimitives) => Record<string, unknown>;
toModel?: () => BlockModel; toModel?: () => BlockModel;
transformer?: () => BaseBlockTransformer; transformer?: (
transformerConfig: Map<string, unknown>
) => BaseBlockTransformer;
}): BlockSchemaType { }): BlockSchemaType {
const schema = { const schema = {
version: metadata.version, version: metadata.version,
@@ -51,6 +51,8 @@ export class BaseBlockTransformer<Props extends object = object> {
); );
} }
constructor(public readonly transformerConfigs: Map<string, unknown>) {}
fromSnapshot({ fromSnapshot({
json, json,
}: FromSnapshotPayload): Promise<SnapshotNode<Props>> | SnapshotNode<Props> { }: FromSnapshotPayload): Promise<SnapshotNode<Props>> | SnapshotNode<Props> {
@@ -82,7 +82,8 @@ type TransformerMiddlewareOptions = {
assetsManager: AssetsManager; assetsManager: AssetsManager;
slots: TransformerSlots; slots: TransformerSlots;
docCRUD: DocCRUD; docCRUD: DocCRUD;
adapterConfigs: Map<string, string>; adapterConfigs: Map<string, unknown>;
transformerConfigs: Map<string, unknown>;
}; };
export type TransformerMiddleware = ( export type TransformerMiddleware = (
@@ -56,6 +56,8 @@ const BATCH_SIZE = 100;
export class Transformer { export class Transformer {
private readonly _adapterConfigs = new Map<string, string>(); private readonly _adapterConfigs = new Map<string, string>();
private readonly _transformerConfigs = new Map<string, unknown>();
private readonly _assetsManager: AssetsManager; private readonly _assetsManager: AssetsManager;
private readonly _schema: Schema; private readonly _schema: Schema;
@@ -72,6 +74,11 @@ export class Transformer {
blockToSnapshot = (model: DraftModel): BlockSnapshot | undefined => { blockToSnapshot = (model: DraftModel): BlockSnapshot | undefined => {
try { try {
const snapshot = this._blockToSnapshot(model); const snapshot = this._blockToSnapshot(model);
if (!snapshot) {
return;
}
BlockSnapshotSchema.parse(snapshot); BlockSnapshotSchema.parse(snapshot);
return snapshot; return snapshot;
@@ -354,24 +361,28 @@ export class Transformer {
docCRUD: this._docCRUD, docCRUD: this._docCRUD,
assetsManager: this._assetsManager, assetsManager: this._assetsManager,
adapterConfigs: this._adapterConfigs, adapterConfigs: this._adapterConfigs,
transformerConfigs: this._transformerConfigs,
}); });
}); });
} }
private _blockToSnapshot(model: DraftModel): BlockSnapshot { private _blockToSnapshot(model: DraftModel): BlockSnapshot | null {
this._slots.beforeExport.emit({ this._slots.beforeExport.emit({
type: 'block', type: 'block',
model, model,
}); });
const schema = this._getSchema(model.flavour); const schema = this._getSchema(model.flavour);
const transformer = this._getTransformer(schema); const transformer = this._getTransformer(schema);
const snapshotLeaf = transformer.toSnapshot({ const snapshotLeaf = transformer.toSnapshot({
model, model,
assets: this._assetsManager, assets: this._assetsManager,
}); });
const children = model.children.map(child => { const children = model.children
return this._blockToSnapshot(child); .map(child => {
}); return this._blockToSnapshot(child);
})
.filter(Boolean) as BlockSnapshot[];
const snapshot: BlockSnapshot = { const snapshot: BlockSnapshot = {
type: 'block', type: 'block',
...snapshotLeaf, ...snapshotLeaf,
@@ -489,7 +500,10 @@ export class Transformer {
} }
private _getTransformer(schema: BlockSchemaType) { private _getTransformer(schema: BlockSchemaType) {
return schema.transformer?.() ?? new BaseBlockTransformer(); return (
schema.transformer?.(this._transformerConfigs) ??
new BaseBlockTransformer(this._transformerConfigs)
);
} }
private async _insertBlockTree( private async _insertBlockTree(
@@ -204,13 +204,13 @@ test.describe('edgeless text block', () => {
)!; )!;
return container.getBoundingClientRect(); return container.getBoundingClientRect();
}); });
const model = await page.evaluate(() => { const modelXYWH = await page.evaluate(() => {
const block = window.host.view.getBlock( const block = window.host.view.getBlock(
'4' '4'
) as EdgelessTextBlockComponent; ) 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.width).toBeCloseTo(bound.w);
expect(rect.height).toBeCloseTo(bound.h); expect(rect.height).toBeCloseTo(bound.h);
}); });