snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('fractional-indexing', () => ({
|
||||
generateKeyBetween: vi.fn(),
|
||||
generateNKeysBetween: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
|
||||
import { ungroupCommand } from '../command/group-api.js';
|
||||
|
||||
type TestElement = {
|
||||
id: string;
|
||||
index: string;
|
||||
group: TestElement | null;
|
||||
childElements: TestElement[];
|
||||
removeChildren?: (elements: TestElement[]) => void;
|
||||
addChildren?: (elements: TestElement[]) => void;
|
||||
};
|
||||
|
||||
const mockedGenerateNKeysBetween = vi.mocked(generateNKeysBetween);
|
||||
const mockedGenerateKeyBetween = vi.mocked(generateKeyBetween);
|
||||
|
||||
const createElement = (
|
||||
id: string,
|
||||
index: string,
|
||||
group: TestElement | null
|
||||
): TestElement => ({
|
||||
id,
|
||||
index,
|
||||
group,
|
||||
childElements: [],
|
||||
});
|
||||
|
||||
const createUngroupFixture = () => {
|
||||
const parent = createElement('parent', 'p0', null);
|
||||
const left = createElement('left', 'a0', parent);
|
||||
const right = createElement('right', 'a0', parent);
|
||||
const group = createElement('group', 'm0', parent);
|
||||
const childA = createElement('child-a', 'c0', group);
|
||||
const childB = createElement('child-b', 'c1', group);
|
||||
|
||||
group.childElements = [childB, childA];
|
||||
parent.childElements = [left, group, right];
|
||||
|
||||
parent.removeChildren = vi.fn();
|
||||
parent.addChildren = vi.fn();
|
||||
group.removeChildren = vi.fn();
|
||||
|
||||
const elementOrder = new Map<TestElement, number>([
|
||||
[left, 0],
|
||||
[group, 1],
|
||||
[right, 2],
|
||||
[childA, 3],
|
||||
[childB, 4],
|
||||
]);
|
||||
|
||||
const selectionSet = vi.fn();
|
||||
const gfx = {
|
||||
layer: {
|
||||
compare: (a: TestElement, b: TestElement) =>
|
||||
(elementOrder.get(a) ?? 0) - (elementOrder.get(b) ?? 0),
|
||||
},
|
||||
selection: {
|
||||
set: selectionSet,
|
||||
},
|
||||
};
|
||||
|
||||
const std = {
|
||||
get: vi.fn(() => gfx),
|
||||
store: {
|
||||
transact: (callback: () => void) => callback(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
childA,
|
||||
childB,
|
||||
group,
|
||||
parent,
|
||||
selectionSet,
|
||||
std,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ungroupCommand', () => {
|
||||
beforeEach(() => {
|
||||
mockedGenerateNKeysBetween.mockReset();
|
||||
mockedGenerateKeyBetween.mockReset();
|
||||
});
|
||||
|
||||
test('falls back to open-ended key generation when sibling interval is invalid', () => {
|
||||
const fixture = createUngroupFixture();
|
||||
mockedGenerateNKeysBetween
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('interval reversed');
|
||||
})
|
||||
.mockReturnValueOnce(['n0', 'n1']);
|
||||
|
||||
const next = vi.fn();
|
||||
ungroupCommand(
|
||||
{
|
||||
std: fixture.std,
|
||||
group: fixture.group as any,
|
||||
} as any,
|
||||
next
|
||||
);
|
||||
|
||||
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'a0',
|
||||
'a0',
|
||||
2
|
||||
);
|
||||
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'a0',
|
||||
null,
|
||||
2
|
||||
);
|
||||
expect(fixture.childA.index).toBe('n0');
|
||||
expect(fixture.childB.index).toBe('n1');
|
||||
expect(fixture.selectionSet).toHaveBeenCalledWith({
|
||||
editing: false,
|
||||
elements: ['child-a', 'child-b'],
|
||||
});
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('falls back to key-by-key generation when all batched strategies fail', () => {
|
||||
const fixture = createUngroupFixture();
|
||||
mockedGenerateNKeysBetween.mockImplementation(() => {
|
||||
throw new Error('invalid range');
|
||||
});
|
||||
|
||||
let seq = 0;
|
||||
mockedGenerateKeyBetween.mockImplementation(() => `k${seq++}`);
|
||||
|
||||
ungroupCommand(
|
||||
{
|
||||
std: fixture.std,
|
||||
group: fixture.group as any,
|
||||
} as any,
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
expect(mockedGenerateNKeysBetween).toHaveBeenCalledTimes(4);
|
||||
expect(mockedGenerateKeyBetween).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.childA.index).toBe('k0');
|
||||
expect(fixture.childB.index).toBe('k1');
|
||||
});
|
||||
});
|
||||
2
blocksuite/affine/gfx/group/src/adapter/index.ts
Normal file
2
blocksuite/affine/gfx/group/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
25
blocksuite/affine/gfx/group/src/adapter/markdown/index.ts
Normal file
25
blocksuite/affine/gfx/group/src/adapter/markdown/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ElementToMarkdownAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getGroupTitle } from '../text';
|
||||
|
||||
export const groupToMarkdownAdapterMatcher = ElementToMarkdownAdapterExtension({
|
||||
name: 'group',
|
||||
match: elementModel => elementModel.type === 'group',
|
||||
toAST: elementModel => {
|
||||
const title = getGroupTitle(elementModel);
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = `Group, with title "${title}"`;
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
14
blocksuite/affine/gfx/group/src/adapter/plain-text/index.ts
Normal file
14
blocksuite/affine/gfx/group/src/adapter/plain-text/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ElementToPlainTextAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getGroupTitle } from '../text';
|
||||
|
||||
export const groupToPlainTextAdapterMatcher =
|
||||
ElementToPlainTextAdapterExtension({
|
||||
name: 'group',
|
||||
match: elementModel => elementModel.type === 'group',
|
||||
toAST: elementModel => {
|
||||
const title = getGroupTitle(elementModel);
|
||||
const content = `Group, with title "${title}"`;
|
||||
return { content };
|
||||
},
|
||||
});
|
||||
21
blocksuite/affine/gfx/group/src/adapter/text.ts
Normal file
21
blocksuite/affine/gfx/group/src/adapter/text.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { DeltaInsert } from '@blocksuite/affine/store';
|
||||
|
||||
export function getGroupTitle(elementModel: Record<string, unknown>): string {
|
||||
let title = '';
|
||||
if (elementModel.type !== 'group') {
|
||||
return title;
|
||||
}
|
||||
|
||||
if (
|
||||
'title' in elementModel &&
|
||||
typeof elementModel.title === 'object' &&
|
||||
elementModel.title
|
||||
) {
|
||||
let delta: DeltaInsert[] = [];
|
||||
if ('delta' in elementModel.title) {
|
||||
delta = elementModel.title.delta as DeltaInsert[];
|
||||
}
|
||||
title = delta.map(d => d.insert).join('');
|
||||
}
|
||||
return title;
|
||||
}
|
||||
229
blocksuite/affine/gfx/group/src/command/group-api.ts
Normal file
229
blocksuite/affine/gfx/group/src/command/group-api.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type GroupElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import {
|
||||
batchAddChildren,
|
||||
batchRemoveChildren,
|
||||
type GfxController,
|
||||
GfxControllerIdentifier,
|
||||
type GfxModel,
|
||||
measureOperation,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
|
||||
const getTopLevelOrderedElements = (gfx: GfxController) => {
|
||||
const topLevelElements = gfx.layer.layers.reduce<GfxModel[]>(
|
||||
(elements, layer) => {
|
||||
layer.elements.forEach(element => {
|
||||
if (element.group === null) {
|
||||
elements.push(element as GfxModel);
|
||||
}
|
||||
});
|
||||
|
||||
return elements;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
topLevelElements.sort((a, b) => gfx.layer.compare(a, b));
|
||||
return topLevelElements;
|
||||
};
|
||||
|
||||
const buildUngroupIndexes = (
|
||||
orderedElements: GfxModel[],
|
||||
afterIndex: string | null,
|
||||
beforeIndex: string | null,
|
||||
fallbackAnchorIndex: string
|
||||
) => {
|
||||
if (orderedElements.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const count = orderedElements.length;
|
||||
const tryGenerateN = (left: string | null, right: string | null) => {
|
||||
try {
|
||||
const generated = generateNKeysBetween(left, right, count);
|
||||
return generated.length === count ? generated : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tryGenerateOneByOne = (left: string | null, right: string | null) => {
|
||||
try {
|
||||
let cursor = left;
|
||||
return orderedElements.map(() => {
|
||||
cursor = generateKeyBetween(cursor, right);
|
||||
return cursor;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Preferred: keep ungrouped children in the original group slot.
|
||||
return (
|
||||
tryGenerateN(afterIndex, beforeIndex) ??
|
||||
// Fallback: ignore the upper bound when legacy/broken data has reversed interval.
|
||||
tryGenerateN(afterIndex, null) ??
|
||||
// Fallback: use group index as anchor when sibling interval is unavailable.
|
||||
tryGenerateN(fallbackAnchorIndex, null) ??
|
||||
// Last resort: always valid.
|
||||
tryGenerateN(null, null) ??
|
||||
// Defensive fallback for unexpected library behavior.
|
||||
tryGenerateOneByOne(null, null) ??
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
export const createGroupCommand: Command<
|
||||
{ elements: GfxModel[] | string[] },
|
||||
{ groupId: string }
|
||||
> = (ctx, next) => {
|
||||
const { std, elements } = ctx;
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
|
||||
const groups = gfx.layer.canvasElements.filter(
|
||||
el => el.type === 'group'
|
||||
) as GroupElementModel[];
|
||||
const groupId = 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}`,
|
||||
});
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
next({ groupId });
|
||||
};
|
||||
|
||||
export const createGroupFromSelectedCommand: Command<
|
||||
{},
|
||||
{ groupId: string }
|
||||
> = (ctx, next) => {
|
||||
measureOperation('edgeless:create-group-from-selected', () => {
|
||||
const { std } = ctx;
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const { selection, surface } = gfx;
|
||||
|
||||
if (!surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selection.selectedElements.length === 0 ||
|
||||
!selection.selectedElements.every(
|
||||
element =>
|
||||
element.group === selection.firstElement.group &&
|
||||
!(element.group instanceof MindmapElementModel)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = selection.firstElement.group;
|
||||
let groupId: string | undefined;
|
||||
std.store.transact(() => {
|
||||
const [_, result] = std.command.exec(createGroupCommand, {
|
||||
elements: selection.selectedElements,
|
||||
});
|
||||
|
||||
if (!result.groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
groupId = result.groupId;
|
||||
const group = surface.getElementById(groupId);
|
||||
|
||||
if (parent !== null && group) {
|
||||
batchRemoveChildren(parent, selection.selectedElements);
|
||||
batchAddChildren(parent, [group]);
|
||||
}
|
||||
});
|
||||
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
selection.set({
|
||||
editing: false,
|
||||
elements: [groupId],
|
||||
});
|
||||
|
||||
next({ groupId });
|
||||
});
|
||||
};
|
||||
|
||||
export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
measureOperation('edgeless:ungroup', () => {
|
||||
const { std, group } = ctx;
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const { selection } = gfx;
|
||||
const parent = group.group;
|
||||
const elements = [...group.childElements];
|
||||
|
||||
if (group instanceof MindmapElementModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedElements = [...elements].sort((a, b) =>
|
||||
gfx.layer.compare(a, b)
|
||||
);
|
||||
const siblings = parent
|
||||
? [...parent.childElements].sort((a, b) => gfx.layer.compare(a, b))
|
||||
: getTopLevelOrderedElements(gfx);
|
||||
const groupPosition = siblings.indexOf(group);
|
||||
const beforeSiblingIndex =
|
||||
groupPosition > 0 ? (siblings[groupPosition - 1]?.index ?? null) : null;
|
||||
const afterSiblingIndex =
|
||||
groupPosition === -1
|
||||
? null
|
||||
: (siblings[groupPosition + 1]?.index ?? null);
|
||||
const nextIndexes = buildUngroupIndexes(
|
||||
orderedElements,
|
||||
beforeSiblingIndex,
|
||||
afterSiblingIndex,
|
||||
group.index
|
||||
);
|
||||
|
||||
std.store.transact(() => {
|
||||
if (parent !== null) {
|
||||
batchRemoveChildren(parent, [group]);
|
||||
}
|
||||
|
||||
batchRemoveChildren(group, elements);
|
||||
|
||||
// keep relative index order of group children after ungroup
|
||||
orderedElements.forEach((element, idx) => {
|
||||
const index = nextIndexes[idx];
|
||||
if (element.index !== index) {
|
||||
element.index = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (parent !== null) {
|
||||
batchAddChildren(parent, orderedElements);
|
||||
}
|
||||
});
|
||||
|
||||
selection.set({
|
||||
editing: false,
|
||||
elements: orderedElements.map(ele => ele.id),
|
||||
});
|
||||
next();
|
||||
});
|
||||
};
|
||||
1
blocksuite/affine/gfx/group/src/command/index.ts
Normal file
1
blocksuite/affine/gfx/group/src/command/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './group-api';
|
||||
14
blocksuite/affine/gfx/group/src/effects.ts
Normal file
14
blocksuite/affine/gfx/group/src/effects.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { EdgelessGroupTitleEditor } from './text/edgeless-group-title-editor';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'edgeless-group-title-editor',
|
||||
EdgelessGroupTitleEditor
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-group-title-editor': EdgelessGroupTitleEditor;
|
||||
}
|
||||
}
|
||||
57
blocksuite/affine/gfx/group/src/element-view.ts
Normal file
57
blocksuite/affine/gfx/group/src/element-view.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
GfxElementModelView,
|
||||
GfxViewInteractionExtension,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { mountGroupTitleEditor } from './text/edgeless-group-title-editor';
|
||||
|
||||
export class GroupElementView extends GfxElementModelView<GroupElementModel> {
|
||||
static override type: string = 'group';
|
||||
|
||||
override onCreated(): void {
|
||||
super.onCreated();
|
||||
|
||||
this._initDblClickToEdit();
|
||||
}
|
||||
|
||||
private _initDblClickToEdit(): void {
|
||||
this.on('dblclick', () => {
|
||||
const rootId = this.std.store.root?.id;
|
||||
if (!rootId) {
|
||||
console.error(
|
||||
'GroupElementView: rootId is not found when dblclick to edit'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const edgeless = this.std.view.getBlock(rootId);
|
||||
|
||||
if (edgeless && !this.model.isLocked()) {
|
||||
mountGroupTitleEditor(this.model, edgeless);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const GroupInteraction = GfxViewInteractionExtension<GroupElementView>(
|
||||
GroupElementView.type,
|
||||
{
|
||||
handleResize(context) {
|
||||
const empty = () => {};
|
||||
context.model.descendantElements.forEach(elm => {
|
||||
if (elm instanceof GroupElementModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.add(elm);
|
||||
});
|
||||
context.delete(context.model);
|
||||
|
||||
return {
|
||||
onResizeStart: empty,
|
||||
onResizeMove: empty,
|
||||
onResizeEnd: empty,
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
36
blocksuite/affine/gfx/group/src/group-watcher.ts
Normal file
36
blocksuite/affine/gfx/group/src/group-watcher.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type SurfaceBlockModel,
|
||||
SurfaceGroupLikeModel,
|
||||
type SurfaceMiddleware,
|
||||
surfaceMiddlewareExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
|
||||
const groupRelationWatcher: SurfaceMiddleware = (
|
||||
surface: SurfaceBlockModel
|
||||
) => {
|
||||
const disposables = [
|
||||
surface.elementUpdated.subscribe(({ id, props, local }) => {
|
||||
if (!local) return;
|
||||
|
||||
const element = surface.getElementById(id)!;
|
||||
|
||||
// remove the group if it has no children
|
||||
if (
|
||||
element instanceof SurfaceGroupLikeModel &&
|
||||
props['childIds'] &&
|
||||
element.childIds.length === 0
|
||||
) {
|
||||
surface.deleteElement(id);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposables.forEach(d => d.unsubscribe());
|
||||
};
|
||||
};
|
||||
|
||||
export const groupRelationWatcherExtension = surfaceMiddlewareExtension(
|
||||
'group-relation-watcher',
|
||||
groupRelationWatcher
|
||||
);
|
||||
6
blocksuite/affine/gfx/group/src/index.ts
Normal file
6
blocksuite/affine/gfx/group/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './adapter';
|
||||
export * from './command';
|
||||
export * from './element-view';
|
||||
export * from './renderer';
|
||||
export * from './text/text';
|
||||
export * from './toolbar/config';
|
||||
45
blocksuite/affine/gfx/group/src/interaction-ext.ts
Normal file
45
blocksuite/affine/gfx/group/src/interaction-ext.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { InteractivityExtension } from '@blocksuite/std/gfx';
|
||||
|
||||
export class GroupInteractionExtension extends InteractivityExtension {
|
||||
static override key = 'group-selection';
|
||||
|
||||
override mounted(): void {
|
||||
this.action.onElementSelect(context => {
|
||||
const { candidates, suggest } = context;
|
||||
const { activeGroup } = this.gfx.selection;
|
||||
let target = context.target;
|
||||
|
||||
if (activeGroup && activeGroup.hasDescendant(target)) {
|
||||
const groups = target.groups;
|
||||
const activeGroupIdx = groups.indexOf(activeGroup);
|
||||
|
||||
if (activeGroupIdx !== -1) {
|
||||
target =
|
||||
groups
|
||||
.slice(0, activeGroupIdx)
|
||||
.findLast(
|
||||
group =>
|
||||
group instanceof GroupElementModel &&
|
||||
candidates.includes(group)
|
||||
) ?? target;
|
||||
}
|
||||
} else {
|
||||
const groups = target.groups;
|
||||
|
||||
target =
|
||||
groups.findLast(group => {
|
||||
return (
|
||||
group instanceof GroupElementModel && candidates.includes(group)
|
||||
);
|
||||
}) ?? target;
|
||||
}
|
||||
|
||||
if (target !== context.target) {
|
||||
suggest({
|
||||
id: target.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
6
blocksuite/affine/gfx/group/src/renderer/consts.ts
Normal file
6
blocksuite/affine/gfx/group/src/renderer/consts.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { FontFamily } from '@blocksuite/affine-model';
|
||||
|
||||
export const GROUP_TITLE_FONT = FontFamily.Inter;
|
||||
export const GROUP_TITLE_FONT_SIZE = 12;
|
||||
export const GROUP_TITLE_PADDING = [2, 0];
|
||||
export const GROUP_TITLE_OFFSET = 4;
|
||||
62
blocksuite/affine/gfx/group/src/renderer/dom-renderer.ts
Normal file
62
blocksuite/affine/gfx/group/src/renderer/dom-renderer.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
|
||||
import { FontWeight, type GroupElementModel } from '@blocksuite/affine-model';
|
||||
|
||||
import {
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from './consts';
|
||||
import { titleRenderParams } from './utils';
|
||||
|
||||
export const GroupDomRendererExtension = DomElementRendererExtension(
|
||||
'group',
|
||||
(model: GroupElementModel, domElement, renderer) => {
|
||||
const { zoom } = renderer.viewport;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
|
||||
const renderParams = titleRenderParams(model, zoom);
|
||||
model.externalXYWH = renderParams.titleBound.serialize();
|
||||
|
||||
domElement.innerHTML = '';
|
||||
domElement.style.outlineColor = '';
|
||||
domElement.style.outlineWidth = '';
|
||||
domElement.style.outlineStyle = '';
|
||||
|
||||
const elements = renderer.provider.selectedElements?.() || [];
|
||||
|
||||
const renderTitle = () => {
|
||||
const { text } = renderParams;
|
||||
const titleElement = document.createElement('div');
|
||||
titleElement.style.transform = `translate(0, -100%)`;
|
||||
titleElement.style.fontFamily = GROUP_TITLE_FONT;
|
||||
titleElement.style.fontWeight = `${FontWeight.Regular}`;
|
||||
titleElement.style.fontStyle = 'normal';
|
||||
titleElement.style.fontSize = `${GROUP_TITLE_FONT_SIZE}px`;
|
||||
titleElement.style.color = renderer.getPropertyValue('--affine-blue');
|
||||
titleElement.style.textAlign = 'left';
|
||||
titleElement.style.padding = `${GROUP_TITLE_PADDING[0]}px ${GROUP_TITLE_PADDING[1]}px`;
|
||||
titleElement.textContent = text;
|
||||
domElement.replaceChildren(titleElement);
|
||||
};
|
||||
|
||||
if (elements.includes(model.id)) {
|
||||
if (model.showTitle) {
|
||||
renderTitle();
|
||||
} else {
|
||||
domElement.style.outlineColor =
|
||||
renderer.getPropertyValue('--affine-blue');
|
||||
domElement.style.outlineWidth = '2px';
|
||||
domElement.style.outlineStyle = 'solid';
|
||||
}
|
||||
} else if (model.childElements.some(child => elements.includes(child.id))) {
|
||||
domElement.style.outlineColor = '#8FD1FF';
|
||||
domElement.style.outlineWidth = '2px';
|
||||
domElement.style.outlineStyle = 'solid';
|
||||
}
|
||||
|
||||
domElement.style.width = `${w * zoom}px`;
|
||||
domElement.style.height = `${h * zoom}px`;
|
||||
domElement.style.overflow = 'visible';
|
||||
domElement.style.pointerEvents = 'none';
|
||||
}
|
||||
);
|
||||
67
blocksuite/affine/gfx/group/src/renderer/element-renderer.ts
Normal file
67
blocksuite/affine/gfx/group/src/renderer/element-renderer.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type CanvasRenderer,
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import { titleRenderParams } from './utils';
|
||||
|
||||
export const group: ElementRenderer<GroupElementModel> = (
|
||||
model,
|
||||
ctx,
|
||||
matrix,
|
||||
renderer
|
||||
) => {
|
||||
const { xywh } = model;
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const elements = renderer.provider.selectedElements?.() || [];
|
||||
|
||||
const renderParams = titleRenderParams(model, renderer.viewport.zoom);
|
||||
model.externalXYWH = renderParams.titleBound.serialize();
|
||||
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
if (elements.includes(model.id)) {
|
||||
if (model.showTitle) {
|
||||
renderTitle(model, ctx, renderer, renderParams);
|
||||
} else {
|
||||
ctx.lineWidth = 2 / renderer.viewport.zoom;
|
||||
ctx.strokeStyle = renderer.getPropertyValue('--affine-blue');
|
||||
ctx.strokeRect(0, 0, bound.w, bound.h);
|
||||
}
|
||||
} else if (model.childElements.some(child => elements.includes(child.id))) {
|
||||
ctx.lineWidth = 2 / renderer.viewport.zoom;
|
||||
ctx.strokeStyle = '#8FD1FF';
|
||||
ctx.strokeRect(0, 0, bound.w, bound.h);
|
||||
|
||||
if (model.showTitle) renderTitle(model, ctx, renderer, renderParams);
|
||||
}
|
||||
};
|
||||
|
||||
export const GroupElementRendererExtension = ElementRendererExtension(
|
||||
'group',
|
||||
group
|
||||
);
|
||||
|
||||
function renderTitle(
|
||||
model: GroupElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
renderer: CanvasRenderer,
|
||||
renderParams: ReturnType<typeof titleRenderParams>
|
||||
) {
|
||||
const { text, lineHeight, font, padding, offset, titleBound } = renderParams;
|
||||
|
||||
model.externalXYWH = titleBound.serialize();
|
||||
|
||||
ctx.translate(0, -offset);
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = renderer.getPropertyValue('--affine-blue');
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, padding[0], -lineHeight / 2 - padding[1]);
|
||||
}
|
||||
2
blocksuite/affine/gfx/group/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/group/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
77
blocksuite/affine/gfx/group/src/renderer/utils.ts
Normal file
77
blocksuite/affine/gfx/group/src/renderer/utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getLineWidth,
|
||||
truncateTextByWidth,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { FontWeight } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import {
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from './consts';
|
||||
|
||||
export function titleRenderParams(group: GroupElementModel, zoom: number) {
|
||||
let text = group.title.toString().trim();
|
||||
const font = getGroupTitleFont(zoom);
|
||||
const lineWidth = getLineWidth(text, font);
|
||||
const lineHeight = getLineHeight(
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE / zoom,
|
||||
'normal'
|
||||
);
|
||||
const bound = group.elementBound;
|
||||
const padding = [
|
||||
GROUP_TITLE_PADDING[0] / zoom,
|
||||
GROUP_TITLE_PADDING[1] / zoom,
|
||||
];
|
||||
const offset = GROUP_TITLE_OFFSET / zoom;
|
||||
|
||||
let titleWidth = lineWidth + padding[0] * 2;
|
||||
const titleHeight = lineHeight + padding[1] * 2;
|
||||
|
||||
if (titleWidth > bound.w) {
|
||||
text = truncateTextByWidth(text, font, bound.w - 10);
|
||||
text = text.slice(0, text.length - 1) + '..';
|
||||
titleWidth = bound.w;
|
||||
}
|
||||
|
||||
return {
|
||||
font,
|
||||
bound,
|
||||
text,
|
||||
titleWidth,
|
||||
titleHeight,
|
||||
offset,
|
||||
lineHeight,
|
||||
padding,
|
||||
titleBound: new Bound(
|
||||
bound.x,
|
||||
bound.y - titleHeight - offset,
|
||||
titleWidth,
|
||||
titleHeight
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function titleBound(group: GroupElementModel, zoom: number) {
|
||||
const { titleWidth, titleHeight, bound } = titleRenderParams(group, zoom);
|
||||
|
||||
return new Bound(bound.x, bound.y - titleHeight, titleWidth, titleHeight);
|
||||
}
|
||||
|
||||
function getGroupTitleFont(zoom: number) {
|
||||
const fontSize = GROUP_TITLE_FONT_SIZE / zoom;
|
||||
const font = getFontString({
|
||||
fontSize,
|
||||
fontFamily: GROUP_TITLE_FONT,
|
||||
fontWeight: FontWeight.Regular,
|
||||
fontStyle: 'normal',
|
||||
});
|
||||
|
||||
return font;
|
||||
}
|
||||
21
blocksuite/affine/gfx/group/src/store.ts
Normal file
21
blocksuite/affine/gfx/group/src/store.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type StoreExtensionContext,
|
||||
StoreExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import {
|
||||
groupToMarkdownAdapterMatcher,
|
||||
groupToPlainTextAdapterMatcher,
|
||||
} from './adapter';
|
||||
import { groupRelationWatcherExtension } from './group-watcher';
|
||||
|
||||
export class GroupStoreExtension extends StoreExtensionProvider {
|
||||
override name = 'affine-group-gfx';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(groupToPlainTextAdapterMatcher);
|
||||
context.register(groupToMarkdownAdapterMatcher);
|
||||
context.register(groupRelationWatcherExtension);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
ShadowlessElement,
|
||||
stdContext,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { consume } from '@lit/context';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from '../renderer/consts';
|
||||
|
||||
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(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [group.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
const groupEditor = new EdgelessGroupTitleEditor();
|
||||
groupEditor.group = group;
|
||||
|
||||
mountElm.append(groupEditor);
|
||||
}
|
||||
|
||||
export class EdgelessGroupTitleEditor extends WithDisposable(
|
||||
ShadowlessElement
|
||||
) {
|
||||
get inlineEditor() {
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this.inlineEditor?.rootElement;
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.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.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 group!: GroupElementModel;
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
}
|
||||
33
blocksuite/affine/gfx/group/src/text/text.ts
Normal file
33
blocksuite/affine/gfx/group/src/text/text.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
import { EdgelessGroupTitleEditor } from './edgeless-group-title-editor';
|
||||
|
||||
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(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [group.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
const groupEditor = new EdgelessGroupTitleEditor();
|
||||
groupEditor.group = group;
|
||||
|
||||
mountElm.append(groupEditor);
|
||||
}
|
||||
106
blocksuite/affine/gfx/group/src/toolbar/config.ts
Normal file
106
blocksuite/affine/gfx/group/src/toolbar/config.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
GroupElementModel,
|
||||
NoteBlockModel,
|
||||
NoteBlockSchema,
|
||||
NoteDisplayMode,
|
||||
SurfaceRefBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} 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,
|
||||
InsertIntoPageIcon,
|
||||
UngroupIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
|
||||
import { ungroupCommand } from '../command';
|
||||
import { mountGroupTitleEditor } from '../text/text';
|
||||
|
||||
export const groupToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.insert-into-page',
|
||||
label: 'Insert into Page',
|
||||
tooltip: 'Insert into Page',
|
||||
icon: InsertIntoPageIcon(),
|
||||
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.findLast(
|
||||
note =>
|
||||
matchModels(note, [NoteBlockModel]) &&
|
||||
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
)?.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;
|
||||
|
||||
for (const model of models) {
|
||||
ctx.command.exec(ungroupCommand, { group: model });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const groupToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:group'),
|
||||
config: groupToolbarConfig,
|
||||
});
|
||||
34
blocksuite/affine/gfx/group/src/view.ts
Normal file
34
blocksuite/affine/gfx/group/src/view.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { effects } from './effects';
|
||||
import { GroupElementView, GroupInteraction } from './element-view';
|
||||
import { GroupInteractionExtension } from './interaction-ext';
|
||||
import {
|
||||
GroupDomRendererExtension,
|
||||
GroupElementRendererExtension,
|
||||
} from './renderer';
|
||||
import { groupToolbarExtension } from './toolbar/config';
|
||||
|
||||
export class GroupViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-group-gfx';
|
||||
|
||||
override effect(): void {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(GroupElementRendererExtension);
|
||||
context.register(GroupDomRendererExtension);
|
||||
context.register(GroupElementView);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(groupToolbarExtension);
|
||||
context.register(GroupInteraction);
|
||||
context.register(GroupInteractionExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user