mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
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.
This commit is contained in:
@@ -21,7 +21,7 @@ export const SurfaceBlockSchema = defineBlockSchema({
|
|||||||
metadata: {
|
metadata: {
|
||||||
version: 5,
|
version: 5,
|
||||||
role: 'hub',
|
role: 'hub',
|
||||||
parent: ['affine:page'],
|
parent: ['@root'],
|
||||||
children: [
|
children: [
|
||||||
'affine:frame',
|
'affine:frame',
|
||||||
'affine:image',
|
'affine:image',
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
StrokeStyleSchema,
|
StrokeStyleSchema,
|
||||||
} from '../../consts/note';
|
} from '../../consts/note';
|
||||||
import { type Color, ColorSchema, DefaultTheme } from '../../themes';
|
import { type Color, ColorSchema, DefaultTheme } from '../../themes';
|
||||||
import { TableModelFlavour } from '../table';
|
|
||||||
|
|
||||||
export const NoteZodSchema = z
|
export const NoteZodSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -74,22 +73,12 @@ export const NoteBlockSchema = defineBlockSchema({
|
|||||||
metadata: {
|
metadata: {
|
||||||
version: 1,
|
version: 1,
|
||||||
role: 'hub',
|
role: 'hub',
|
||||||
parent: ['affine:page'],
|
parent: ['@root'],
|
||||||
children: [
|
children: [
|
||||||
'affine:paragraph',
|
'@content',
|
||||||
'affine:list',
|
|
||||||
'affine:code',
|
|
||||||
'affine:divider',
|
|
||||||
'affine:database',
|
'affine:database',
|
||||||
'affine:data-view',
|
'affine:data-view',
|
||||||
'affine:image',
|
|
||||||
'affine:bookmark',
|
|
||||||
'affine:attachment',
|
|
||||||
'affine:surface-ref',
|
|
||||||
'affine:embed-*',
|
|
||||||
'affine:latex',
|
|
||||||
'affine:callout',
|
'affine:callout',
|
||||||
TableModelFlavour,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
toModel: () => {
|
toModel: () => {
|
||||||
|
|||||||
@@ -54,6 +54,34 @@ const TestInvalidNoteBlockSchemaExtension = BlockSchemaExtension(
|
|||||||
TestInvalidNoteBlockSchema
|
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 = [
|
const extensions = [
|
||||||
RootBlockSchemaExtension,
|
RootBlockSchemaExtension,
|
||||||
ParagraphBlockSchemaExtension,
|
ParagraphBlockSchemaExtension,
|
||||||
@@ -62,6 +90,8 @@ const extensions = [
|
|||||||
DividerBlockSchemaExtension,
|
DividerBlockSchemaExtension,
|
||||||
TestCustomNoteBlockSchemaExtension,
|
TestCustomNoteBlockSchemaExtension,
|
||||||
TestInvalidNoteBlockSchemaExtension,
|
TestInvalidNoteBlockSchemaExtension,
|
||||||
|
TestRoleBlockSchemaExtension,
|
||||||
|
TestParagraphBlockSchemaExtension,
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultDocId = 'doc0';
|
const defaultDocId = 'doc0';
|
||||||
@@ -128,4 +158,28 @@ describe('schema', () => {
|
|||||||
return call[0] instanceof SchemaValidateError;
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import type { BlockModel } from './block-model.js';
|
|||||||
const FlavourSchema = z.string();
|
const FlavourSchema = z.string();
|
||||||
const ParentSchema = z.array(z.string()).optional();
|
const ParentSchema = z.array(z.string()).optional();
|
||||||
const ContentSchema = z.array(z.string()).optional();
|
const ContentSchema = z.array(z.string()).optional();
|
||||||
const role = ['root', 'hub', 'content'] as const;
|
const RoleSchema = z.string();
|
||||||
const RoleSchema = z.enum(role);
|
|
||||||
|
|
||||||
export type RoleType = (typeof role)[number];
|
export type RoleType = 'root' | 'content' | string;
|
||||||
|
|
||||||
export interface InternalPrimitives {
|
export interface InternalPrimitives {
|
||||||
Text: (input?: Y.Text | string) => Text;
|
Text: (input?: Y.Text | string) => Text;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class Schema {
|
|||||||
if (!parentFlavour) {
|
if (!parentFlavour) {
|
||||||
throw new SchemaValidateError(
|
throw new SchemaValidateError(
|
||||||
schema.model.flavour,
|
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(
|
private _validateParent(
|
||||||
child: BlockSchemaType,
|
child: BlockSchemaType,
|
||||||
parent: BlockSchemaType
|
parent: BlockSchemaType
|
||||||
): boolean {
|
): boolean {
|
||||||
const _childFlavour = child.model.flavour;
|
const _childFlavour = child.model.flavour;
|
||||||
const _parentFlavour = parent.model.flavour;
|
const _parentFlavour = parent.model.flavour;
|
||||||
|
const _childRole = child.model.role;
|
||||||
|
const _parentRole = parent.model.role;
|
||||||
|
|
||||||
const childValidFlavours = child.model.parent || ['*'];
|
const childValidFlavourOrRole = child.model.parent || ['*'];
|
||||||
const parentValidFlavours = parent.model.children || ['*'];
|
const parentValidFlavourOrRole = parent.model.children || ['*'];
|
||||||
|
|
||||||
return parentValidFlavours.some(parentValidFlavour => {
|
return parentValidFlavourOrRole.some(parentValidFlavourOrRole => {
|
||||||
return childValidFlavours.some(childValidFlavour => {
|
return childValidFlavourOrRole.some(childValidFlavourOrRole => {
|
||||||
if (parentValidFlavour === '*' && childValidFlavour === '*') {
|
if (
|
||||||
|
parentValidFlavourOrRole === '*' &&
|
||||||
|
childValidFlavourOrRole === '*'
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentValidFlavour === '*') {
|
if (parentValidFlavourOrRole === '*') {
|
||||||
return this._matchFlavour(childValidFlavour, _parentFlavour);
|
return this._matchFlavourOrRole(
|
||||||
|
childValidFlavourOrRole,
|
||||||
|
_parentFlavour,
|
||||||
|
_childRole,
|
||||||
|
_parentRole
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (childValidFlavour === '*') {
|
if (childValidFlavourOrRole === '*') {
|
||||||
return this._matchFlavour(_childFlavour, parentValidFlavour);
|
return this._matchFlavourOrRole(
|
||||||
|
_childFlavour,
|
||||||
|
parentValidFlavourOrRole,
|
||||||
|
_childRole,
|
||||||
|
_parentRole
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this._matchFlavour(_childFlavour, parentValidFlavour) &&
|
this._matchFlavourOrRole(
|
||||||
this._matchFlavour(childValidFlavour, _parentFlavour)
|
_childFlavour,
|
||||||
|
parentValidFlavourOrRole,
|
||||||
|
_childRole,
|
||||||
|
_parentRole
|
||||||
|
) &&
|
||||||
|
this._matchFlavourOrRole(
|
||||||
|
childValidFlavourOrRole,
|
||||||
|
_parentFlavour,
|
||||||
|
_childRole,
|
||||||
|
_parentRole
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -120,7 +171,6 @@ export class Schema {
|
|||||||
|
|
||||||
private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) {
|
private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) {
|
||||||
const childRole = child.model.role;
|
const childRole = child.model.role;
|
||||||
const parentRole = parent.model.role;
|
|
||||||
const childFlavour = child.model.flavour;
|
const childFlavour = child.model.flavour;
|
||||||
const parentFlavour = parent.model.flavour;
|
const parentFlavour = parent.model.flavour;
|
||||||
|
|
||||||
@@ -130,20 +180,6 @@ export class Schema {
|
|||||||
`Root block cannot have parent: ${parentFlavour}.`
|
`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) {
|
isValid(child: string, parent: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user