feat(editor): command for ungroup and group (#11116)

This commit is contained in:
Saul-Mirone
2025-03-24 06:28:43 +00:00
parent 63762b75a1
commit ef1ed383cb
25 changed files with 321 additions and 147 deletions

View File

@@ -1,152 +0,0 @@
import {
GROUP_TITLE_FONT_SIZE,
GROUP_TITLE_OFFSET,
GROUP_TITLE_PADDING,
} from '@blocksuite/affine-block-surface';
import type { GroupElementModel } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std/inline';
import { Bound } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
export class EdgelessGroupTitleEditor extends WithDisposable(
ShadowlessElement
) {
get inlineEditor() {
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor?.rootElement;
}
get gfx() {
return this.edgeless.std.get(GfxControllerIdentifier);
}
get selection() {
return this.gfx.selection;
}
private _unmount() {
// dispose in advance to avoid execute `this.remove()` twice
this.disposables.dispose();
this.group.showTitle = true;
this.selection.set({
elements: [this.group.id],
editing: false,
});
this.remove();
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const dispatcher = this.edgeless.std.event;
this.updateComplete
.then(() => {
if (!this.inlineEditor) return;
this.inlineEditor.selectAll();
this.group.showTitle = false;
this.inlineEditor.slots.renderComplete.subscribe(() => {
this.requestUpdate();
});
this.disposables.add(
dispatcher.add('keyDown', ctx => {
const state = ctx.get('keyboardState');
if (state.raw.key === 'Enter' && !state.raw.isComposing) {
this._unmount();
return true;
}
requestAnimationFrame(() => {
this.requestUpdate();
});
return false;
})
);
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
this._unmount();
}
);
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
if (!this.group.externalXYWH) {
console.error('group.externalXYWH is not set');
return nothing;
}
const viewport = this.gfx.viewport;
const bound = Bound.deserialize(this.group.externalXYWH);
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
const inlineEditorStyle = styleMap({
transformOrigin: 'top left',
borderRadius: '2px',
width: 'fit-content',
maxHeight: '30px',
height: 'fit-content',
padding: `${GROUP_TITLE_PADDING[1]}px ${GROUP_TITLE_PADDING[0]}px`,
fontSize: GROUP_TITLE_FONT_SIZE + 'px',
position: 'absolute',
left: x + 'px',
top: `${y - GROUP_TITLE_OFFSET + 2}px`,
minWidth: '8px',
fontFamily: 'var(--affine-font-family)',
color: 'var(--affine-text-primary-color)',
background: 'var(--affine-white-10)',
outline: 'none',
zIndex: '1',
border: `1px solid
var(--affine-primary-color)`,
boxShadow: 'var(--affine-active-shadow)',
});
return html`<rich-text
.yText=${this.group.title}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${inlineEditorStyle}
></rich-text>`;
}
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@property({ attribute: false })
accessor group!: GroupElementModel;
@query('rich-text')
accessor richText!: RichText;
}

View File

@@ -1,98 +0,0 @@
import { toast } from '@blocksuite/affine-components/toast';
import {
DEFAULT_NOTE_HEIGHT,
GroupElementModel,
NoteBlockModel,
NoteBlockSchema,
NoteDisplayMode,
SurfaceRefBlockSchema,
} from '@blocksuite/affine-model';
import { type ToolbarModuleConfig } from '@blocksuite/affine-shared/services';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { getRootBlock } from '@blocksuite/affine-widget-edgeless-toolbar';
import { Bound } from '@blocksuite/global/gfx';
import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit';
import { EdgelessRootService } from '../../edgeless-root-service';
import { mountGroupTitleEditor } from '../../utils/text';
export const builtinGroupToolbarConfig = {
actions: [
{
id: 'a.insert-into-page',
label: 'Insert into Page',
showLabel: true,
tooltip: 'Insert into Page',
icon: PageIcon(),
when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length === 1,
run(ctx) {
const model = ctx.getCurrentModelByType(GroupElementModel);
if (!model) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
const { id: groupId, xywh } = model;
let lastNoteId = rootModel.children
.filter(
note =>
matchModels(note, [NoteBlockModel]) &&
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
)
.pop()?.id;
if (!lastNoteId) {
const bounds = Bound.deserialize(xywh);
bounds.y += bounds.h;
bounds.h = DEFAULT_NOTE_HEIGHT;
lastNoteId = ctx.store.addBlock(
NoteBlockSchema.model.flavour,
{ xywh: bounds.serialize() },
rootModel.id
);
}
ctx.store.addBlock(
SurfaceRefBlockSchema.model.flavour,
{ reference: groupId, refFlavour: 'group' },
lastNoteId
);
toast(ctx.host, 'Group has been inserted into doc');
},
},
{
id: 'b.rename',
tooltip: 'Rename',
icon: EditIcon(),
when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length === 1,
run(ctx) {
const model = ctx.getCurrentModelByType(GroupElementModel);
if (!model) return;
const rootBlock = getRootBlock(ctx);
if (!rootBlock) return;
mountGroupTitleEditor(model, rootBlock);
},
},
{
id: 'b.ungroup',
tooltip: 'Ungroup',
icon: UngroupIcon(),
run(ctx) {
const models = ctx.getSurfaceModelsByType(GroupElementModel);
if (!models.length) return;
const edgelessService = ctx.std.get(EdgelessRootService);
for (const model of models) {
edgelessService.ungroup(model);
}
},
},
],
when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length > 0,
} as const satisfies ToolbarModuleConfig;

View File

@@ -1,6 +1,7 @@
import { edgelessTextToolbarExtension } from '@blocksuite/affine-block-edgeless-text';
import { frameToolbarExtension } from '@blocksuite/affine-block-frame';
import { connectorToolbarExtension } from '@blocksuite/affine-gfx-connector';
import { groupToolbarExtension } from '@blocksuite/affine-gfx-group';
import { mindmapToolbarExtension } from '@blocksuite/affine-gfx-mindmap';
import { shapeToolbarExtension } from '@blocksuite/affine-gfx-shape';
import { textToolbarExtension } from '@blocksuite/affine-gfx-text';
@@ -9,16 +10,12 @@ import { BlockFlavourIdentifier } from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { builtinBrushToolbarConfig } from './brush';
import { builtinGroupToolbarConfig } from './group';
import { builtinLockedToolbarConfig, builtinMiscToolbarConfig } from './misc';
export const EdgelessElementToolbarExtension: ExtensionType[] = [
frameToolbarExtension,
ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:surface:group'),
config: builtinGroupToolbarConfig,
}),
groupToolbarExtension,
ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:surface:brush'),

View File

@@ -3,6 +3,11 @@ import {
EdgelessCRUDIdentifier,
getSurfaceComponent,
} from '@blocksuite/affine-block-surface';
import {
createGroupCommand,
createGroupFromSelectedCommand,
ungroupCommand,
} from '@blocksuite/affine-gfx-group';
import {
ConnectorElementModel,
DEFAULT_CONNECTOR_MODE,
@@ -29,7 +34,6 @@ import {
} from '@blocksuite/icons/lit';
import { html } from 'lit';
import { EdgelessRootService } from '../../edgeless-root-service';
import { renderAlignmentMenu } from './alignment';
import { moreActions } from './more';
@@ -137,10 +141,8 @@ export const builtinMiscToolbarConfig = {
const models = ctx.getSurfaceModels();
if (models.length < 2) return;
const service = ctx.std.get(EdgelessRootService);
// TODO(@fundon): should be a command
service.createGroupFromSelected();
ctx.command.exec(createGroupFromSelectedCommand);
},
},
{
@@ -268,8 +270,9 @@ export const builtinMiscToolbarConfig = {
return;
}
const service = ctx.std.get(EdgelessRootService);
const groupId = service.createGroup([topElement, ...otherElements]);
const [_, { groupId }] = ctx.command.exec(createGroupCommand, {
elements: [topElement, ...otherElements],
});
if (groupId) {
const element = ctx.std
@@ -335,11 +338,9 @@ export const builtinLockedToolbarConfig = {
ctx.store.captureSync();
const service = ctx.std.get(EdgelessRootService);
for (const element of elements) {
if (element instanceof GroupElementModel) {
service.ungroup(element);
ctx.command.exec(ungroupCommand, { group: element });
} else {
element.lockedBySelf = false;
}

View File

@@ -11,6 +11,7 @@ import {
EdgelessCRUDIdentifier,
getSurfaceComponent,
} from '@blocksuite/affine-block-surface';
import { createGroupFromSelectedCommand } from '@blocksuite/affine-gfx-group';
import {
AttachmentBlockModel,
BookmarkBlockModel,
@@ -45,7 +46,6 @@ import {
ResetIcon,
} from '@blocksuite/icons/lit';
import { EdgelessRootService } from '../../edgeless-root-service';
import { duplicate } from '../../utils/clipboard-utils';
import { getSortedCloneElements } from '../../utils/clone-utils';
import { moveConnectors } from '../../utils/connector';
@@ -92,8 +92,7 @@ export const moreActions = [
return !models.some(model => ctx.matchModel(model, FrameBlockModel));
},
run(ctx) {
const service = ctx.std.get(EdgelessRootService);
service.createGroupFromSelected();
ctx.command.exec(createGroupFromSelectedCommand);
},
},
],

View File

@@ -3,6 +3,10 @@ import { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-te
import { isNoteBlock } from '@blocksuite/affine-block-surface';
import { toast } from '@blocksuite/affine-components/toast';
import { mountConnectorLabelEditor } from '@blocksuite/affine-gfx-connector';
import {
createGroupFromSelectedCommand,
ungroupCommand,
} from '@blocksuite/affine-gfx-group';
import {
getNearestTranslation,
isElementOutsideViewport,
@@ -227,7 +231,7 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
!this.rootComponent.service.selection.editing
) {
ctx.get('keyboardState').event.preventDefault();
rootComponent.service.createGroupFromSelected();
rootComponent.std.command.exec(createGroupFromSelectedCommand);
}
},
'Shift-Mod-g': ctx => {
@@ -239,7 +243,9 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
!selection.firstElement.isLocked()
) {
ctx.get('keyboardState').event.preventDefault();
rootComponent.service.ungroup(selection.firstElement);
rootComponent.std.command.exec(ungroupCommand, {
group: selection.firstElement,
});
}
},
'Mod-a': ctx => {

View File

@@ -10,8 +10,6 @@ import {
} from '@blocksuite/affine-block-surface';
import {
type ConnectorElementModel,
type GroupElementModel,
MindmapElementModel,
RootBlockSchema,
} from '@blocksuite/affine-model';
import type { BlockStdScope } from '@blocksuite/block-std';
@@ -166,70 +164,6 @@ export class EdgelessRootService extends RootService implements SurfaceContext {
);
}
createGroup(elements: GfxModel[] | string[]) {
const groups = this.elements.filter(
el => el.type === 'group'
) as GroupElementModel[];
const groupId = this.crud.addElement('group', {
children: elements.reduce(
(pre, el) => {
const id = typeof el === 'string' ? el : el.id;
pre[id] = true;
return pre;
},
{} as Record<string, true>
),
title: `Group ${groups.length + 1}`,
});
return groupId;
}
/**
* Create a group from selected elements, if the selected elements are in the same group
* @returns the id of the created group
*/
createGroupFromSelected() {
const { selection } = this;
if (
selection.selectedElements.length === 0 ||
!selection.selectedElements.every(
element =>
element.group === selection.firstElement.group &&
!(element.group instanceof MindmapElementModel)
)
) {
return;
}
const parent = selection.firstElement.group as GroupElementModel;
if (parent !== null) {
selection.selectedElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parent.removeChild(element);
});
}
const groupId = this.createGroup(selection.selectedElements);
if (!groupId) {
return;
}
const group = this.surface.getElementById(groupId);
if (parent !== null && group) {
parent.addChild(group);
}
selection.set({
editing: false,
elements: [groupId],
});
return groupId;
}
createTemplateJob(
type: 'template' | 'sticker',
center?: { x: number; y: number }
@@ -353,46 +287,6 @@ export class EdgelessRootService extends RootService implements SurfaceContext {
this.viewport.smoothZoom(clamp(this.zoom + step, ZOOM_MIN, ZOOM_MAX));
}
ungroup(group: GroupElementModel) {
const { selection } = this;
const elements = group.childElements;
const parent = group.group as GroupElementModel;
if (group instanceof MindmapElementModel) {
return;
}
if (parent !== null) {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parent.removeChild(group);
}
elements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(element);
});
// keep relative index order of group children after ungroup
elements
.sort((a, b) => this.layer.compare(a, b))
.forEach(element => {
this.doc.transact(() => {
element.index = this.layer.generateIndex();
});
});
if (parent !== null) {
elements.forEach(element => {
parent.addChild(element);
});
}
selection.set({
editing: false,
elements: elements.map(ele => ele.id),
});
}
override unmounted() {
super.unmounted();

View File

@@ -9,6 +9,7 @@ import {
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import { mountConnectorLabelEditor } from '@blocksuite/affine-gfx-connector';
import { mountGroupTitleEditor } from '@blocksuite/affine-gfx-group';
import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape';
import { addText, mountTextElementEditor } from '@blocksuite/affine-gfx-text';
import type {
@@ -52,7 +53,6 @@ import type { EdgelessRootBlockComponent } from '../index.js';
import { prepareCloneData } from '../utils/clone-utils.js';
import { calPanDelta } from '../utils/panning-utils.js';
import { isCanvasElement, isEdgelessTextBlock } from '../utils/query.js';
import { mountGroupTitleEditor } from '../utils/text.js';
import { DefaultModeDragType } from './default-tool-ext/ext.js';
export class DefaultTool extends BaseTool {

View File

@@ -1,33 +0,0 @@
import type { GroupElementModel } from '@blocksuite/affine-model';
import type { BlockComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { EdgelessGroupTitleEditor } from '../components/text/edgeless-group-title-editor.js';
export function mountGroupTitleEditor(
group: GroupElementModel,
edgeless: BlockComponent
) {
const mountElm = edgeless.querySelector('.edgeless-mount-point');
if (!mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
const gfx = edgeless.std.get(GfxControllerIdentifier);
gfx.tool.setTool('default');
gfx.selection.set({
elements: [group.id],
editing: true,
});
const groupEditor = new EdgelessGroupTitleEditor();
groupEditor.group = group;
groupEditor.edgeless = edgeless;
mountElm.append(groupEditor);
}

View File

@@ -1,4 +1,5 @@
import { effects as gfxConnectorEffects } from '@blocksuite/affine-gfx-connector/effects';
import { effects as gfxGroupEffects } from '@blocksuite/affine-gfx-group/effects';
import { effects as gfxMindmapEffects } from '@blocksuite/affine-gfx-mindmap/effects';
import { effects as gfxNoteEffects } from '@blocksuite/affine-gfx-note/effects';
import { effects as gfxShapeEffects } from '@blocksuite/affine-gfx-shape/effects';
@@ -19,7 +20,6 @@ import {
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget,
} from './edgeless/components/rects/edgeless-selected-rect.js';
import { EdgelessGroupTitleEditor } from './edgeless/components/text/edgeless-group-title-editor.js';
import { EdgelessBrushMenu } from './edgeless/components/toolbar/brush/brush-menu.js';
import { EdgelessBrushToolButton } from './edgeless/components/toolbar/brush/brush-tool-button.js';
import { EdgelessSlideMenu } from './edgeless/components/toolbar/common/slide-menu.js';
@@ -81,7 +81,6 @@ export function effects() {
registerGfxEffects();
registerWidgets();
registerEdgelessToolbarComponents();
registerEdgelessEditorComponents();
registerMiscComponents();
}
@@ -101,6 +100,7 @@ function registerGfxEffects() {
gfxNoteEffects();
gfxConnectorEffects();
gfxMindmapEffects();
gfxGroupEffects();
}
function registerWidgets() {
@@ -144,13 +144,6 @@ function registerEdgelessToolbarComponents() {
customElements.define('toolbar-arrow-up-icon', ToolbarArrowUpIcon);
}
function registerEdgelessEditorComponents() {
customElements.define(
'edgeless-group-title-editor',
EdgelessGroupTitleEditor
);
}
function registerMiscComponents() {
// Modal and menu components
customElements.define('affine-custom-modal', AffineCustomModal);
@@ -203,7 +196,6 @@ declare global {
'edgeless-navigator-black-background': EdgelessNavigatorBlackBackgroundWidget;
'edgeless-dragging-area-rect': EdgelessDraggingAreaRectWidget;
'edgeless-selected-rect': EdgelessSelectedRectWidget;
'edgeless-group-title-editor': EdgelessGroupTitleEditor;
'edgeless-brush-menu': EdgelessBrushMenu;
'edgeless-brush-tool-button': EdgelessBrushToolButton;
'edgeless-slide-menu': EdgelessSlideMenu;