From 9f3cf271e3ef2592b3fef80bfa47b226d764c466 Mon Sep 17 00:00:00 2001 From: Saul-Mirone Date: Tue, 18 Mar 2025 01:58:59 +0000 Subject: [PATCH] feat(editor): support user provided role and role schema (#10939) Let me analyze the key changes in this diff: 1. **Role System Changes**: - Changed from a fixed enum of roles (`root`, `hub`, `content`) to a more flexible string-based system - Removed strict role hierarchy validation rules (hub/content/root relationships) - Added support for role-based matching using `@` prefix (e.g., `@root`, `@content`) 2. **Schema Validation Updates**: - Added new `_matchFlavourOrRole` method to handle both flavour and role-based matching - Updated `_validateParent` to consider both roles and flavours when validating parent-child relationships - Simplified `_validateRole` by removing specific role hierarchy constraints 3. **Block Schema Changes**: - Updated parent/children references in various block schemas to use the new `@` prefix notation - Changed parent definitions from `['affine:page']` to `['@root']` in several blocks - Updated children definitions to use role-based references (e.g., `['@content']`) 4. **Test Updates**: - Added new test cases for role-based schema validation - Introduced new test block schemas (`TestRoleBlockSchema`, `TestParagraphBlockSchema`) to verify role-based functionality This appears to be a significant architectural change that makes the block schema system more flexible by: 1. Moving away from hardcoded role hierarchies 2. Introducing a more dynamic role-based relationship system 3. Supporting both flavour-based and role-based parent-child relationships 4. Using the `@` prefix convention to distinguish role references from flavour references The changes make the system more extensible while maintaining backward compatibility with existing flavour-based relationships. --- .../blocks/block-surface/src/surface-model.ts | 2 +- .../model/src/blocks/note/note-model.ts | 15 +--- .../store/src/__tests__/schema.unit.spec.ts | 54 +++++++++++ .../framework/store/src/model/block/zod.ts | 5 +- .../framework/store/src/schema/schema.ts | 90 +++++++++++++------ 5 files changed, 122 insertions(+), 44 deletions(-) diff --git a/blocksuite/affine/blocks/block-surface/src/surface-model.ts b/blocksuite/affine/blocks/block-surface/src/surface-model.ts index 3db3869206..03137f13e2 100644 --- a/blocksuite/affine/blocks/block-surface/src/surface-model.ts +++ b/blocksuite/affine/blocks/block-surface/src/surface-model.ts @@ -21,7 +21,7 @@ export const SurfaceBlockSchema = defineBlockSchema({ metadata: { version: 5, role: 'hub', - parent: ['affine:page'], + parent: ['@root'], children: [ 'affine:frame', 'affine:image', diff --git a/blocksuite/affine/model/src/blocks/note/note-model.ts b/blocksuite/affine/model/src/blocks/note/note-model.ts index 4967fc7698..46a0261daf 100644 --- a/blocksuite/affine/model/src/blocks/note/note-model.ts +++ b/blocksuite/affine/model/src/blocks/note/note-model.ts @@ -25,7 +25,6 @@ import { StrokeStyleSchema, } from '../../consts/note'; import { type Color, ColorSchema, DefaultTheme } from '../../themes'; -import { TableModelFlavour } from '../table'; export const NoteZodSchema = z .object({ @@ -74,22 +73,12 @@ export const NoteBlockSchema = defineBlockSchema({ metadata: { version: 1, role: 'hub', - parent: ['affine:page'], + parent: ['@root'], children: [ - 'affine:paragraph', - 'affine:list', - 'affine:code', - 'affine:divider', + '@content', 'affine:database', 'affine:data-view', - 'affine:image', - 'affine:bookmark', - 'affine:attachment', - 'affine:surface-ref', - 'affine:embed-*', - 'affine:latex', 'affine:callout', - TableModelFlavour, ], }, toModel: () => { diff --git a/blocksuite/framework/store/src/__tests__/schema.unit.spec.ts b/blocksuite/framework/store/src/__tests__/schema.unit.spec.ts index 7828e6e245..039877a2a2 100644 --- a/blocksuite/framework/store/src/__tests__/schema.unit.spec.ts +++ b/blocksuite/framework/store/src/__tests__/schema.unit.spec.ts @@ -54,6 +54,34 @@ const TestInvalidNoteBlockSchemaExtension = BlockSchemaExtension( TestInvalidNoteBlockSchema ); +const TestRoleBlockSchema = defineBlockSchema({ + flavour: 'affine:note-block-role-test', + metadata: { + version: 1, + role: 'content', + parent: ['affine:note'], + children: ['@test'], + }, + props: internal => ({ + text: internal.Text(), + }), +}); + +const TestRoleBlockSchemaExtension = BlockSchemaExtension(TestRoleBlockSchema); + +const TestParagraphBlockSchema = defineBlockSchema({ + flavour: 'affine:test-paragraph', + metadata: { + version: 1, + role: 'test', + parent: ['@content'], + }, +}); + +const TestParagraphBlockSchemaExtension = BlockSchemaExtension( + TestParagraphBlockSchema +); + const extensions = [ RootBlockSchemaExtension, ParagraphBlockSchemaExtension, @@ -62,6 +90,8 @@ const extensions = [ DividerBlockSchemaExtension, TestCustomNoteBlockSchemaExtension, TestInvalidNoteBlockSchemaExtension, + TestRoleBlockSchemaExtension, + TestParagraphBlockSchemaExtension, ]; const defaultDocId = 'doc0'; @@ -128,4 +158,28 @@ describe('schema', () => { return call[0] instanceof SchemaValidateError; }); }); + + it('should be able to validate schema by role', () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const doc = createTestDoc(); + const rootId = doc.addBlock('affine:page', {}); + const noteId = doc.addBlock('affine:note', {}, rootId); + const roleId = doc.addBlock('affine:note-block-role-test', {}, noteId); + + doc.addBlock('affine:paragraph', {}, roleId); + doc.addBlock('affine:paragraph', {}, roleId); + + expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => { + return call[0] instanceof SchemaValidateError; + }); + + consoleMock.mockClear(); + doc.addBlock('affine:test-paragraph', {}, roleId); + doc.addBlock('affine:test-paragraph', {}, roleId); + expect(consoleMock).not.toBeCalled(); + + expect(doc.getBlocksByFlavour('affine:test-paragraph')).toHaveLength(2); + }); }); diff --git a/blocksuite/framework/store/src/model/block/zod.ts b/blocksuite/framework/store/src/model/block/zod.ts index 2458b65295..6472a1c7c1 100644 --- a/blocksuite/framework/store/src/model/block/zod.ts +++ b/blocksuite/framework/store/src/model/block/zod.ts @@ -8,10 +8,9 @@ import type { BlockModel } from './block-model.js'; const FlavourSchema = z.string(); const ParentSchema = z.array(z.string()).optional(); const ContentSchema = z.array(z.string()).optional(); -const role = ['root', 'hub', 'content'] as const; -const RoleSchema = z.enum(role); +const RoleSchema = z.string(); -export type RoleType = (typeof role)[number]; +export type RoleType = 'root' | 'content' | string; export interface InternalPrimitives { Text: (input?: Y.Text | string) => Text; diff --git a/blocksuite/framework/store/src/schema/schema.ts b/blocksuite/framework/store/src/schema/schema.ts index a74dd47d30..744c4ba667 100644 --- a/blocksuite/framework/store/src/schema/schema.ts +++ b/blocksuite/framework/store/src/schema/schema.ts @@ -59,7 +59,7 @@ export class Schema { if (!parentFlavour) { throw new SchemaValidateError( schema.model.flavour, - 'Hub/Content must have parent.' + 'None root block must have parent.' ); } @@ -86,33 +86,84 @@ export class Schema { ); } + private _matchFlavourOrRole( + childValue: string, + parentValue: string, + childRole: string, + parentRole: string + ): boolean { + // Check if either value starts with '@' indicating it's a role + const isChildRole = childValue.startsWith('@'); + const isParentRole = parentValue.startsWith('@'); + + // If both are roles, do exact match + if (isChildRole && isParentRole) { + return childValue === parentValue; + } + // If child is role, compare with parent's actual role + if (isChildRole) { + return childValue === `@${parentRole}`; + } + // If parent is role, compare with child's actual role + if (isParentRole) { + return parentValue === `@${childRole}`; + } + // If neither is role, use flavour matching + return this._matchFlavour(childValue, parentValue); + } + private _validateParent( child: BlockSchemaType, parent: BlockSchemaType ): boolean { const _childFlavour = child.model.flavour; const _parentFlavour = parent.model.flavour; + const _childRole = child.model.role; + const _parentRole = parent.model.role; - const childValidFlavours = child.model.parent || ['*']; - const parentValidFlavours = parent.model.children || ['*']; + const childValidFlavourOrRole = child.model.parent || ['*']; + const parentValidFlavourOrRole = parent.model.children || ['*']; - return parentValidFlavours.some(parentValidFlavour => { - return childValidFlavours.some(childValidFlavour => { - if (parentValidFlavour === '*' && childValidFlavour === '*') { + return parentValidFlavourOrRole.some(parentValidFlavourOrRole => { + return childValidFlavourOrRole.some(childValidFlavourOrRole => { + if ( + parentValidFlavourOrRole === '*' && + childValidFlavourOrRole === '*' + ) { return true; } - if (parentValidFlavour === '*') { - return this._matchFlavour(childValidFlavour, _parentFlavour); + if (parentValidFlavourOrRole === '*') { + return this._matchFlavourOrRole( + childValidFlavourOrRole, + _parentFlavour, + _childRole, + _parentRole + ); } - if (childValidFlavour === '*') { - return this._matchFlavour(_childFlavour, parentValidFlavour); + if (childValidFlavourOrRole === '*') { + return this._matchFlavourOrRole( + _childFlavour, + parentValidFlavourOrRole, + _childRole, + _parentRole + ); } return ( - this._matchFlavour(_childFlavour, parentValidFlavour) && - this._matchFlavour(childValidFlavour, _parentFlavour) + this._matchFlavourOrRole( + _childFlavour, + parentValidFlavourOrRole, + _childRole, + _parentRole + ) && + this._matchFlavourOrRole( + childValidFlavourOrRole, + _parentFlavour, + _childRole, + _parentRole + ) ); }); }); @@ -120,7 +171,6 @@ export class Schema { private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) { const childRole = child.model.role; - const parentRole = parent.model.role; const childFlavour = child.model.flavour; const parentFlavour = parent.model.flavour; @@ -130,20 +180,6 @@ export class Schema { `Root block cannot have parent: ${parentFlavour}.` ); } - - if (childRole === 'hub' && parentRole === 'content') { - throw new SchemaValidateError( - childFlavour, - `Hub block cannot be child of content block: ${parentFlavour}.` - ); - } - - if (childRole === 'content' && parentRole === 'root') { - throw new SchemaValidateError( - childFlavour, - `Content block can only be child of hub block or itself. But get: ${parentFlavour}.` - ); - } } isValid(child: string, parent: string) {