feat(editor): gfx template package (#11480)

This commit is contained in:
Saul-Mirone
2025-04-06 12:24:13 +00:00
parent 41499c1cd6
commit bb1270061a
35 changed files with 189 additions and 42 deletions
@@ -0,0 +1,344 @@
import { generateElementId, sortIndex } from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import { Bound } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { BlockSnapshot, SnapshotNode } from '@blocksuite/store';
import type { SlotBlockPayload, TemplateJob } from './template.js';
export const replaceIdMiddleware = (job: TemplateJob) => {
const regeneratedIdMap = new Map<string, string>();
job.slots.beforeInsert.subscribe(payload => {
switch (payload.type) {
case 'block':
regenerateBlockId(payload.data);
break;
}
});
const regenerateBlockId = (data: SlotBlockPayload['data']) => {
const { blockJson } = data;
const newId = regeneratedIdMap.has(blockJson.id)
? regeneratedIdMap.get(blockJson.id)!
: job.model.doc.workspace.idGenerator();
if (!regeneratedIdMap.has(blockJson.id)) {
regeneratedIdMap.set(blockJson.id, newId);
}
blockJson.id = newId;
data.parent = data.parent
? (regeneratedIdMap.get(data.parent) ?? data.parent)
: undefined;
if (blockJson.flavour === 'affine:surface-ref') {
assertType<
SnapshotNode<{
reference: string;
}>
>(blockJson);
blockJson.props['reference'] =
regeneratedIdMap.get(blockJson.props['reference']) ?? '';
}
if (blockJson.flavour === 'affine:surface') {
const elements: Record<string, Record<string, unknown>> = {};
const defered: string[] = [];
Object.entries(
blockJson.props.elements as Record<string, Record<string, unknown>>
).forEach(([id, val]) => {
const newId = generateElementId();
regeneratedIdMap.set(id, newId);
val.id = newId;
elements[newId] = val;
if (['connector', 'group'].includes(val['type'] as string)) {
defered.push(newId);
}
});
blockJson.children.forEach(block => {
regeneratedIdMap.set(block.id, job.model.doc.workspace.idGenerator());
});
defered.forEach(id => {
const element = elements[id]!;
switch (element['type'] as string) {
case 'group':
{
const children = element['children'] as {
json: Record<string, boolean>;
};
const newChildrenJson: Record<string, boolean> = {};
Object.entries(children.json).forEach(([key, val]) => {
newChildrenJson[regeneratedIdMap.get(key) ?? key] = val;
});
children.json = newChildrenJson;
}
break;
case 'connector':
{
const target = element['target'] as { id?: string };
if (target.id) {
element['target'] = {
...target,
id: regeneratedIdMap.get(target.id),
};
}
const source = element['source'] as { id?: string };
if (source.id) {
element['source'] = {
...source,
id: regeneratedIdMap.get(source.id),
};
}
}
break;
}
});
blockJson.props.elements = elements;
}
// remap childElementIds of frame
if (blockJson.flavour === 'affine:frame') {
assertType<Record<string, boolean>>(blockJson.props.childElementIds);
const newChildElementIds: Record<string, boolean> = {};
Object.entries(blockJson.props.childElementIds).forEach(([key, val]) => {
newChildElementIds[regeneratedIdMap.get(key) ?? key] = val;
});
blockJson.props.childElementIds = newChildElementIds;
}
};
};
export const createInsertPlaceMiddleware = (targetPlace: Bound) => {
return (job: TemplateJob) => {
if (job.type !== 'template') return;
let templateBound: Bound | null = null;
let offset: {
x: number;
y: number;
};
job.slots.beforeInsert.subscribe(blockData => {
if (blockData.type === 'template') {
templateBound = blockData.bound;
if (templateBound) {
offset = {
x: targetPlace.x - templateBound.x,
y: targetPlace.y - templateBound.y,
};
templateBound.x = targetPlace.x;
templateBound.y = targetPlace.y;
}
} else {
if (templateBound && offset) changePosition(blockData.data.blockJson);
}
});
const ignoreType = new Set(['group', 'connector']);
const changePosition = (blockJson: BlockSnapshot) => {
if (blockJson.props.xywh) {
const bound = Bound.deserialize(blockJson.props['xywh'] as string);
blockJson.props['xywh'] = new Bound(
bound.x + offset.x,
bound.y + offset.y,
bound.w,
bound.h
).serialize();
}
if (blockJson.flavour === 'affine:surface') {
Object.entries(
blockJson.props.elements as Record<string, Record<string, unknown>>
).forEach(([_, val]) => {
const type = val['type'] as string;
if (ignoreType.has(type) && val['xywh']) {
delete val['xywh'];
}
if (val['xywh']) {
const bound = Bound.deserialize(val['xywh'] as string);
val['xywh'] = new Bound(
bound.x + offset.x,
bound.y + offset.y,
bound.w,
bound.h
).serialize();
}
if (type === 'connector') {
(['target', 'source'] as const).forEach(prop => {
const propVal = val[prop];
assertType<ConnectorElementModel['target']>(propVal);
if (propVal['id'] || !propVal['position']) return;
const pos = propVal['position'];
propVal['position'] = [pos[0] + offset.x, pos[1] + offset.y];
});
}
});
}
};
};
};
export const createStickerMiddleware = (
center: {
x: number;
y: number;
},
getIndex: () => string
) => {
return (job: TemplateJob) => {
job.slots.beforeInsert.subscribe(blockData => {
if (blockData.type === 'block') {
changeInserPosition(blockData.data.blockJson);
}
});
const changeInserPosition = (blockJson: BlockSnapshot) => {
if (blockJson.flavour === 'affine:image' && blockJson.props.xywh) {
const bound = Bound.deserialize(blockJson.props['xywh'] as string);
blockJson.props['xywh'] = new Bound(
center.x - bound.w / 2,
center.y - bound.h / 2,
bound.w,
bound.h
).serialize();
blockJson.props.index = getIndex();
}
};
};
};
export const createRegenerateIndexMiddleware = (
generateIndex: () => string
) => {
return (job: TemplateJob) => {
job.slots.beforeInsert.subscribe(blockData => {
if (blockData.type === 'template') {
generateIndexMap();
}
if (blockData.type === 'block') {
resetIndex(blockData.data.blockJson);
}
});
const indexMap = new Map<string, string>();
const generateIndexMap = () => {
const indexList: {
id: string;
index: string;
flavour: string;
element?: boolean;
}[] = [];
const frameList: {
id: string;
index: string;
}[] = [];
const groupIndexMap = new Map<
string,
{
index: string;
id: string;
}
>();
job.walk(block => {
if (block.props.index) {
if (block.flavour === 'affine:frame') {
frameList.push({
id: block.id,
index: block.props.index as string,
});
} else {
indexList.push({
id: block.id,
index: block.props.index as string,
flavour: block.flavour,
});
}
}
if (block.flavour === 'affine:surface') {
Object.entries(
block.props.elements as Record<string, Record<string, unknown>>
).forEach(([_, element]) => {
indexList.push({
index: element['index'] as string,
flavour: element['type'] as string,
id: element['id'] as string,
element: true,
});
if (element['type'] === 'group') {
const children = element['children'] as {
json: Record<string, boolean>;
};
const groupIndex = {
index: element['index'] as string,
id: element['id'] as string,
};
Object.keys(children.json).forEach(key => {
groupIndexMap.set(key, groupIndex);
});
}
});
}
});
indexList.sort((a, b) => sortIndex(a, b, groupIndexMap));
frameList.sort((a, b) => sortIndex(a, b, groupIndexMap));
frameList.forEach(index => {
indexMap.set(index.id, generateIndex());
});
indexList.forEach(index => {
indexMap.set(index.id, generateIndex());
});
};
const resetIndex = (blockJson: BlockSnapshot) => {
if (blockJson.props.index) {
blockJson.props.index =
indexMap.get(blockJson.id) ?? blockJson.props.index;
}
if (blockJson.flavour === 'affine:surface') {
Object.entries(
blockJson.props.elements as Record<string, Record<string, unknown>>
).forEach(([_, element]) => {
if (element['index']) {
element['index'] = indexMap.get(element['id'] as string);
}
});
}
};
};
};
@@ -0,0 +1,432 @@
import {
getSurfaceBlock,
type SurfaceBlockModel,
type SurfaceBlockTransformer,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { Bound, getCommonBound } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { BlockStdScope } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import {
type BlockModel,
type BlockSnapshot,
type DocSnapshot,
DocSnapshotSchema,
type SnapshotNode,
type Transformer,
} from '@blocksuite/store';
import { Subject } from 'rxjs';
import type * as Y from 'yjs';
import {
createInsertPlaceMiddleware,
createRegenerateIndexMiddleware,
createStickerMiddleware,
replaceIdMiddleware,
} from './template-middlewares';
/**
* Those block contains other block's id
* should defer the loading
*/
const DEFERED_BLOCK = [
'affine:surface',
'affine:surface-ref',
'affine:frame',
] as const;
/**
* Those block should not be inserted directly
* it should be merged with current existing block
*/
const MERGE_BLOCK = ['affine:surface', 'affine:page'] as const;
type MergeBlockFlavour = (typeof MERGE_BLOCK)[number];
/**
* Template type will affect the inserting behaviour
*/
const TEMPLATE_TYPES = ['template', 'sticker'] as const;
type TemplateType = (typeof TEMPLATE_TYPES)[number];
export type SlotBlockPayload = {
type: 'block';
data: {
blockJson: BlockSnapshot;
parent?: string;
index?: number;
};
};
export type SlotPayload =
| SlotBlockPayload
| {
type: 'template';
template: DocSnapshot;
bound: Bound | null;
};
export type TemplateJobConfig = {
model: SurfaceBlockModel;
type: string;
middlewares: ((job: TemplateJob) => void)[];
};
export class TemplateJob {
static middlewares: ((job: TemplateJob) => void)[] = [];
private _template: DocSnapshot | null = null;
job: Transformer;
model: SurfaceBlockModel;
slots = {
beforeInsert: new Subject<
| SlotBlockPayload
| {
type: 'template';
template: DocSnapshot;
bound: Bound | null;
}
>(),
};
type: TemplateType;
constructor({ model, type, middlewares }: TemplateJobConfig) {
this.job = model.doc.getTransformer();
this.model = model;
this.type = TEMPLATE_TYPES.includes(type as TemplateType)
? (type as TemplateType)
: 'template';
middlewares.forEach(middleware => middleware(this));
TemplateJob.middlewares.forEach(middleware => middleware(this));
}
static create(options: {
model: SurfaceBlockModel;
type: string;
middlewares: ((job: TemplateJob) => void)[];
}) {
return new TemplateJob(options);
}
private _getMergeBlockId(modelData: BlockSnapshot) {
switch (modelData.flavour as MergeBlockFlavour) {
case 'affine:page':
return this.model.doc.root!.id;
case 'affine:surface':
return this.model.id;
}
}
private _getTemplateBound() {
const bounds: Bound[] = [];
this.walk(block => {
if (block.props.xywh) {
bounds.push(Bound.deserialize(block.props['xywh'] as string));
}
if (block.flavour === 'affine:surface') {
const ignoreType = new Set(['connector', 'group']);
Object.entries(
block.props.elements as Record<string, Record<string, unknown>>
).forEach(([_, val]) => {
const type = val['type'] as string;
if (val['xywh'] && !ignoreType.has(type)) {
bounds.push(Bound.deserialize(val['xywh'] as string));
}
if (type === 'connector') {
(['target', 'source'] as const).forEach(prop => {
const propVal = val[prop];
assertType<ConnectorElementModel['source']>(propVal);
if (propVal['id'] || !propVal['position']) return;
const pos = propVal['position'];
if (pos) {
bounds.push(new Bound(pos[0], pos[1], 0, 0));
}
});
}
});
}
});
return getCommonBound(bounds);
}
private _insertToDoc(
modelDataList: {
flavour: string;
json: BlockSnapshot;
modelData: SnapshotNode<object> | null;
parent?: string;
index?: number;
}[]
) {
const doc = this.model.doc;
const mergeIdMapping = new Map<string, string>();
const deferInserting: typeof modelDataList = [];
const insert = (
data: (typeof modelDataList)[number],
defered: boolean = true
) => {
const { flavour, json, modelData, parent, index } = data;
const isMergeBlock = MERGE_BLOCK.includes(flavour as MergeBlockFlavour);
if (isMergeBlock) {
mergeIdMapping.set(json.id, this._getMergeBlockId(json));
}
if (
defered &&
DEFERED_BLOCK.includes(flavour as (typeof DEFERED_BLOCK)[number])
) {
deferInserting.push(data);
return;
} else {
if (isMergeBlock) {
this._mergeProps(
json,
this.model.doc.getModelById(
this._getMergeBlockId(json)
) as BlockModel
);
return;
}
if (!modelData) {
return;
}
doc.addBlock(
modelData.flavour,
{
...modelData.props,
id: modelData.id,
},
parent ? (mergeIdMapping.get(parent) ?? parent) : undefined,
index
);
}
};
modelDataList.forEach(data => insert(data));
deferInserting.forEach(data => insert(data, false));
}
private async _jsonToModelData(json: BlockSnapshot) {
const job = this.job;
const defered: {
snapshot: BlockSnapshot;
parent?: string;
index?: number;
}[] = [];
const modelDataList: {
flavour: string;
json: BlockSnapshot;
modelData: SnapshotNode<object> | null;
parent?: string;
index?: number;
}[] = [];
const toModel = async (
snapshot: BlockSnapshot,
parent?: string,
index?: number,
defer: boolean = true
) => {
if (
defer &&
DEFERED_BLOCK.includes(
snapshot.flavour as (typeof DEFERED_BLOCK)[number]
)
) {
defered.push({
snapshot,
parent,
index,
});
return;
}
const slotData = {
blockJson: snapshot,
parent,
index,
};
this.slots.beforeInsert.next({ type: 'block', data: slotData });
/**
* merge block should not be converted to model data
*/
const modelData = MERGE_BLOCK.includes(
snapshot.flavour as MergeBlockFlavour
)
? null
: ((await job.snapshotToModelData(snapshot)) ?? null);
modelDataList.push({
flavour: snapshot.flavour,
json: snapshot,
modelData,
parent,
index,
});
if (snapshot.children) {
let index = 0;
for (const child of snapshot.children) {
await toModel(child, snapshot.id, index);
++index;
}
}
};
await toModel(json);
for (const json of defered) {
await toModel(json.snapshot, json.parent, json.index, false);
}
return modelDataList;
}
private _mergeProps(from: BlockSnapshot, to: BlockModel) {
switch (from.flavour as MergeBlockFlavour) {
case 'affine:page':
break;
case 'affine:surface':
this._mergeSurfaceElements(
from.props.elements as Record<string, Record<string, unknown>>,
(to as SurfaceBlockModel).elements.getValue()!
);
break;
}
}
private _mergeSurfaceElements(
from: Record<string, Record<string, unknown>>,
to: Y.Map<Y.Map<unknown>>
) {
const schema = this.model.doc.schema.get('affine:surface');
const surfaceTransformer = schema?.transformer?.(
new Map()
) as SurfaceBlockTransformer;
this.model.doc.transact(() => {
const defered: [string, Record<string, unknown>][] = [];
Object.entries(from).forEach(([id, val]) => {
if (['connector', 'group'].includes(val.type as string)) {
defered.push([id, val]);
} else {
to.set(id, surfaceTransformer.elementFromJSON(val));
}
});
defered.forEach(([key, val]) => {
to.set(key, surfaceTransformer.elementFromJSON(val));
});
});
}
async insertTemplate(template: unknown) {
DocSnapshotSchema.parse(template);
assertType<DocSnapshot>(template);
this._template = template;
const templateBound = this._getTemplateBound();
this.slots.beforeInsert.next({
type: 'template',
template: template,
bound: templateBound,
});
const modelDataList = await this._jsonToModelData(template.blocks);
this._insertToDoc(modelDataList);
return templateBound;
}
walk(callback: (block: BlockSnapshot, template: DocSnapshot) => void) {
if (!this._template) {
throw new Error('Template not loaded, please call insertTemplate first');
}
const iterate = (block: BlockSnapshot, template: DocSnapshot) => {
callback(block, template);
if (block.children) {
block.children.forEach(child => iterate(child, template));
}
};
iterate(this._template.blocks, this._template);
}
}
export function createTemplateJob(
std: BlockStdScope,
type: 'template' | 'sticker',
center?: { x: number; y: number }
) {
const surface = getSurfaceBlock(std.store);
if (!surface) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.NoSurfaceModelError,
'This doc is missing surface block in edgeless.'
);
}
const gfx = std.get(GfxControllerIdentifier);
const middlewares: ((job: TemplateJob) => void)[] = [];
const { layer, viewport } = gfx;
const blocks = layer.blocks;
const elements = layer.canvasElements;
if (type === 'template') {
const bounds = [...blocks, ...elements].map(i => Bound.deserialize(i.xywh));
const currentContentBound = getCommonBound(bounds);
if (currentContentBound) {
currentContentBound.x += currentContentBound.w + 20 / viewport.zoom;
middlewares.push(createInsertPlaceMiddleware(currentContentBound));
}
const idxGenerator = layer.createIndexGenerator();
middlewares.push(createRegenerateIndexMiddleware(() => idxGenerator()));
}
if (type === 'sticker') {
middlewares.push(
createStickerMiddleware(center || viewport.center, () =>
layer.generateIndex()
)
);
}
middlewares.push(replaceIdMiddleware);
return TemplateJob.create({
model: surface,
type,
middlewares,
});
}