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

View File

@@ -0,0 +1,20 @@
import { OverlayScrollbar } from './toolbar/overlay-scrollbar';
import { AffineTemplateLoading } from './toolbar/template-loading';
import { EdgelessTemplatePanel } from './toolbar/template-panel';
import { EdgelessTemplateButton } from './toolbar/template-tool-button';
export function effects() {
customElements.define('edgeless-templates-panel', EdgelessTemplatePanel);
customElements.define('overlay-scrollbar', OverlayScrollbar);
customElements.define('edgeless-template-button', EdgelessTemplateButton);
customElements.define('affine-template-loading', AffineTemplateLoading);
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-templates-panel': EdgelessTemplatePanel;
'overlay-scrollbar': OverlayScrollbar;
'edgeless-template-button': EdgelessTemplateButton;
'affine-template-loading': AffineTemplateLoading;
}
}

View File

@@ -0,0 +1,5 @@
export * from './services/template.js';
export * from './template-tool.js';
export * from './toolbar/senior-tool.js';
export * from './toolbar/template-panel.js';
export * from './toolbar/template-type.js';

View File

@@ -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);
}
});
}
};
};
};

View File

@@ -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,
});
}

View File

@@ -0,0 +1,11 @@
import { BaseTool } from '@blocksuite/std/gfx';
export class TemplateTool extends BaseTool {
static override toolName: string = 'template';
}
declare module '@blocksuite/std/gfx' {
interface GfxToolsMap {
template: TemplateTool;
}
}

View File

@@ -0,0 +1,108 @@
import type {
Template,
TemplateCategory,
TemplateManager,
} from './template-type.js';
export const templates: TemplateCategory[] = [];
function lcs(text1: string, text2: string) {
const dp: number[][] = Array.from(
{
length: text1.length + 1,
},
() => Array.from({ length: text2.length + 1 }, () => 0)
);
for (let i = 1; i <= text1.length; i++) {
for (let j = 1; j <= text2.length; j++) {
if (text1[i - 1] === text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[text1.length][text2.length];
}
const extendTemplate: TemplateManager[] = [];
const flat = <T>(arr: T[][]) =>
arr.reduce((pre, current) => {
if (current) {
return pre.concat(current);
}
return pre;
}, []);
export const builtInTemplates = {
list: async (category: string): Promise<Template[]> => {
const extendTemplates = flat(
await Promise.all(extendTemplate.map(manager => manager.list(category)))
);
// eslint-disable-next-line sonarjs/no-empty-collection
const cate = templates.find(cate => cate.name === category);
if (!cate) return extendTemplates;
const result: Template[] =
cate.templates instanceof Function
? await cate.templates()
: await Promise.all(Object.values(cate.templates));
return result.concat(extendTemplates);
},
categories: async (): Promise<string[]> => {
const extendCates = flat(
await Promise.all(extendTemplate.map(manager => manager.categories()))
);
// eslint-disable-next-line sonarjs/no-empty-collection
return templates.map(cate => cate.name).concat(extendCates);
},
search: async (keyword: string, cateName?: string): Promise<Template[]> => {
const candidates: Template[] = flat(
await Promise.all(
extendTemplate.map(manager => manager.search(keyword, cateName))
)
);
keyword = keyword.trim().toLocaleLowerCase();
await Promise.all(
// eslint-disable-next-line sonarjs/no-empty-collection
templates.map(async categroy => {
if (cateName && cateName !== categroy.name) {
return;
}
if (categroy.templates instanceof Function) {
return categroy.templates();
}
return Promise.all(
Object.entries(categroy.templates).map(async ([name, template]) => {
if (
lcs(keyword, (name as string).toLocaleLowerCase()) ===
keyword.length
) {
candidates.push(template);
}
})
);
})
);
return candidates;
},
extend(manager: TemplateManager) {
if (extendTemplate.includes(manager)) return;
extendTemplate.push(manager);
},
} satisfies TemplateManager;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
import {
on,
once,
requestConnectedFrame,
} from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { css, html, LitElement } from 'lit';
import { query } from 'lit/decorators.js';
/**
* A scrollbar that is only visible when the user is interacting with it.
* Append this element to the a container that has a scrollable element. Which means
* the scrollable element should lay on the same level as the overlay-scrollbar.
*
* And the scrollable element should have a `data-scrollable` attribute.
*
* Example:
* ```
* <div class="container">
* <div class="scrollable-element-with-fixed-height" data-scrollable>
* <!--.... very long content ....-->
* </div>
* <overlay-scrollbar></overlay-scrollbar>
* </div>
* ```
*
* Note:
* - It only works with vertical scrollbars.
*/
export class OverlayScrollbar extends LitElement {
static override styles = css`
:host {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 10px;
opacity: 0;
transition: opacity 0.3s;
}
.overlay-handle {
position: absolute;
top: 0;
left: 2px;
background-color: rgba(0, 0, 0, 0.44);
border-radius: 3px;
width: 6px;
}
`;
private readonly _disposable = new DisposableGroup();
private _handleVisible = false;
private _scrollable: HTMLElement | null = null;
private _dragHandle(event: PointerEvent) {
let startY = event.clientY;
this._handleVisible = true;
const dispose = on(document, 'pointermove', evt => {
this._scroll(evt.clientY - startY);
startY = evt.clientY;
});
once(document, 'pointerup', e => {
this._handleVisible = false;
e.stopPropagation();
setTimeout(() => {
this._toggleScrollbarVisible(false);
}, 800);
dispose();
});
}
private _initWheelHandler() {
const container = this.parentElement as HTMLElement;
container.style.contain = 'layout';
container.style.overflow = 'hidden';
let hideScrollbarTimeId: null | ReturnType<typeof setTimeout> = null;
const delayHideScrollbar = () => {
if (hideScrollbarTimeId) clearTimeout(hideScrollbarTimeId);
hideScrollbarTimeId = setTimeout(() => {
this._toggleScrollbarVisible(false);
hideScrollbarTimeId = null;
}, 800);
};
let scrollable: HTMLElement | null = null;
this._disposable.addFromEvent(container, 'wheel', event => {
scrollable = scrollable?.isConnected
? scrollable
: (container.querySelector('[data-scrollable]') as HTMLElement);
this._scrollable = scrollable;
if (!scrollable) return;
// firefox may report a wheel event with deltaMode of value other than 0
// we just simply multiply it by 16 which is common default line height to get the correct value
const scrollDistance =
event.deltaMode === 0 ? event.deltaY : event.deltaY * 16;
this._scroll(scrollDistance ?? 0);
delayHideScrollbar();
});
}
private _scroll(scrollDistance: number) {
const scrollable = this._scrollable!;
if (!scrollable) return;
scrollable.scrollBy({
left: 0,
top: scrollDistance,
behavior: 'instant',
});
requestConnectedFrame(() => {
this._updateScrollbarRect(scrollable);
this._toggleScrollbarVisible(true);
}, this);
}
private _toggleScrollbarVisible(visible: boolean) {
const vis = visible || this._handleVisible ? '1' : '0';
if (this.style.opacity !== vis) {
this.style.opacity = vis;
}
}
private _updateScrollbarRect(rect: {
scrollTop?: number;
clientHeight?: number;
scrollHeight?: number;
}) {
if (rect.scrollHeight !== undefined && rect.clientHeight !== undefined) {
this._handle.style.height = `${(rect.clientHeight / rect.scrollHeight) * 100}%`;
}
if (rect.scrollTop !== undefined && rect.scrollHeight !== undefined) {
this._handle.style.top = `${(rect.scrollTop / rect.scrollHeight) * 100}%`;
}
}
override connectedCallback(): void {
super.connectedCallback();
this._disposable.dispose();
}
override firstUpdated(): void {
this._initWheelHandler();
}
override render() {
return html`<div
class="overlay-handle"
@pointerdown=${this._dragHandle}
></div>`;
}
@query('.overlay-handle')
private accessor _handle!: HTMLDivElement;
}

View File

@@ -0,0 +1,13 @@
import { SeniorToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
import { html } from 'lit';
export const templateSeniorTool = SeniorToolExtension(
'template',
({ block }) => {
return {
name: 'Template',
content: html`<edgeless-template-button .edgeless=${block}>
</edgeless-template-button>`,
};
}
);

View File

@@ -0,0 +1,50 @@
import { css, html, LitElement } from 'lit';
export class AffineTemplateLoading extends LitElement {
static override styles = css`
@keyframes affine-template-block-rotate {
from {
rotate: 0deg;
}
to {
rotate: 360deg;
}
}
.affine-template-block-container {
width: 20px;
height: 20px;
overflow: hidden;
}
.affine-template-block-loading {
display: inline-block;
width: 20px;
height: 20px;
position: relative;
background: conic-gradient(
rgba(30, 150, 235, 1) 90deg,
rgba(0, 0, 0, 0.1) 90deg 360deg
);
border-radius: 50%;
animation: affine-template-block-rotate 1s infinite ease-in;
}
.affine-template-block-loading::before {
content: '';
width: 14px;
height: 14px;
border-radius: 50%;
background-color: white;
position: absolute;
top: 3px;
left: 3px;
}
`;
override render() {
return html`<div class="affine-template-block-container">
<div class="affine-template-block-loading"></div>
</div>`;
}
}

View File

@@ -0,0 +1,517 @@
import {
darkToolbarStyles,
lightToolbarStyles,
} from '@blocksuite/affine-components/toolbar';
import {
EditPropsStore,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
requestConnectedFrame,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { EdgelessDraggableElementController } from '@blocksuite/affine-widget-edgeless-toolbar';
import type { Bound } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import type { BlockComponent } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
import { createTemplateJob } from '../services/template.js';
import { builtInTemplates } from './builtin-templates.js';
import { defaultPreview, Triangle } from './cards.js';
import type { Template } from './template-type.js';
import { cloneDeep } from './utils.js';
export class EdgelessTemplatePanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
position: absolute;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
z-index: 1;
}
.edgeless-templates-panel {
width: 467px;
height: 568px;
border-radius: 12px;
background-color: var(--affine-background-overlay-panel-color);
box-shadow: 0px 10px 80px 0px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
}
${unsafeCSS(lightToolbarStyles('.edgeless-templates-panel'))}
${unsafeCSS(darkToolbarStyles('.edgeless-templates-panel'))}
.search-bar {
padding: 21px 24px;
font-size: 18px;
color: var(--affine-secondary);
border-bottom: 1px solid var(--affine-divider-color);
flex-shrink: 0;
}
.search-input {
border: 0;
color: var(--affine-text-primary-color);
font-size: 20px;
background-color: inherit;
outline: none;
width: 100%;
}
.search-input::placeholder {
color: var(--affine-text-secondary-color);
}
.template-categories {
display: flex;
padding: 6px 8px;
gap: 4px;
overflow-x: scroll;
flex-shrink: 0;
}
.category-entry {
color: var(--affine-text-primary-color);
font-size: 12px;
font-weight: 600;
line-height: 20px;
border-radius: 8px;
flex-shrink: 0;
flex-grow: 0;
width: fit-content;
padding: 4px 9px;
cursor: pointer;
}
.category-entry.selected,
.category-entry:hover {
color: var(--affine-text-primary-color);
background-color: var(--affine-background-tertiary-color);
}
.template-viewport {
position: relative;
flex-grow: 1;
}
.template-scrollcontent {
overflow: hidden;
height: 100%;
width: 100%;
}
.template-list {
padding: 10px;
display: flex;
align-items: flex-start;
align-content: flex-start;
gap: 10px 20px;
flex-wrap: wrap;
}
.template-item {
position: relative;
width: 135px;
height: 80px;
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.02);
background-color: var(--affine-background-primary-color);
border-radius: 4px;
cursor: pointer;
}
.template-item > svg {
display: block;
margin: 0 auto;
width: 135px;
height: 80px;
color: var(--affine-background-primary-color);
}
/* .template-item:hover::before {
content: attr(data-hover-text);
position: absolute;
display: block;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 110px;
border-radius: 8px;
padding: 4px 22px;
box-sizing: border-box;
z-index: 1;
text-align: center;
font-size: 12px;
background-color: var(--affine-primary-color);
color: var(--affine-white);
} */
.template-item:hover::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
border: 1px solid var(--affine-black-10);
border-radius: 4px;
background-color: var(--affine-hover-color);
}
.template-item.loading::before {
display: none;
}
.template-item.loading > affine-template-loading {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.template-item img.template-preview {
object-fit: contain;
width: 100%;
height: 100%;
display: block;
}
.arrow {
bottom: 0;
position: absolute;
transform: translateY(20px);
color: var(--affine-background-overlay-panel-color);
}
`;
static templates = builtInTemplates;
private _fetchJob: null | { cancel: () => void } = null;
draggableController!: EdgelessDraggableElementController<Template>;
private _closePanel() {
if (this.isDragging) return;
this.dispatchEvent(new CustomEvent('closepanel'));
}
private _fetch(fn: (state: { canceled: boolean }) => Promise<unknown>) {
if (this._fetchJob) {
this._fetchJob.cancel();
}
this._loading = true;
const state = { canceled: false };
const job = {
cancel: () => {
state.canceled = true;
},
};
this._fetchJob = job;
fn(state)
.catch(() => {})
.finally(() => {
if (!state.canceled && job === this._fetchJob) {
this._loading = false;
this._fetchJob = null;
}
});
}
private _getLocalSelectedCategory() {
return this.edgeless.std.get(EditPropsStore).getStorage('templateCache');
}
private async _initCategory() {
try {
this._categories = await EdgelessTemplatePanel.templates.categories();
this._currentCategory =
this._getLocalSelectedCategory() ?? this._categories[0];
this._updateTemplates();
} catch (e) {
console.error('Failed to load categories', e);
}
}
private _initDragController() {
if (this.draggableController) return;
this.draggableController = new EdgelessDraggableElementController(this, {
edgeless: this.edgeless,
clickToDrag: true,
standardWidth: 560,
onOverlayCreated: overlay => {
this.isDragging = true;
overlay.mask.style.color = 'transparent';
},
onDrop: (el, bound) => {
this._insertTemplate(el.data, bound)
.finally(() => {
this.isDragging = false;
})
.catch(console.error);
},
onCanceled: () => {
this.isDragging = false;
},
});
}
get gfx() {
return this.edgeless.std.get(GfxControllerIdentifier);
}
private async _insertTemplate(template: Template, bound: Bound) {
this._loadingTemplate = template;
template = cloneDeep(template);
const center = {
x: bound.x + bound.w / 2,
y: bound.y + bound.h / 2,
};
const templateJob = createTemplateJob(
this.edgeless.std,
template.type,
center
);
try {
const { assets } = template;
if (assets) {
await Promise.all(
Object.entries(assets).map(([key, value]) =>
fetch(value)
.then(res => res.blob())
.then(blob => templateJob.job.assets.set(key, blob))
)
);
}
const insertedBound = await templateJob.insertTemplate(template.content);
if (insertedBound && template.type === 'template') {
const padding = 20 / this.gfx.viewport.zoom;
this.gfx.viewport.setViewportByBound(
insertedBound,
[padding, padding, padding, padding],
true
);
}
} finally {
this._loadingTemplate = null;
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.gfx.tool.setTool('default');
}
}
private _updateSearchKeyword(inputEvt: InputEvent) {
this._searchKeyword = (inputEvt.target as HTMLInputElement).value;
this._updateTemplates();
}
private _updateTemplates() {
this._fetch(async state => {
try {
const templates = this._searchKeyword
? await EdgelessTemplatePanel.templates.search(this._searchKeyword)
: await EdgelessTemplatePanel.templates.list(this._currentCategory);
if (state.canceled) return;
this._templates = templates;
} catch (e) {
if (state.canceled) return;
console.error('Failed to load templates', e);
}
});
}
override connectedCallback(): void {
super.connectedCallback();
this._initDragController();
this.addEventListener('keydown', stopPropagation, false);
this._disposables.add(() => {
if (this._currentCategory) {
this.edgeless.std
.get(EditPropsStore)
.setStorage('templateCache', this._currentCategory);
}
});
}
override firstUpdated() {
requestConnectedFrame(() => {
this._disposables.addFromEvent(document, 'click', evt => {
if (this.contains(evt.target as HTMLElement)) {
return;
}
this._closePanel();
});
}, this);
this._disposables.addFromEvent(this, 'click', stopPropagation);
this._disposables.addFromEvent(this, 'wheel', stopPropagation);
this._initCategory().catch(() => {});
}
override render() {
const { _categories, _currentCategory, _templates } = this;
const { draggingElement } = this.draggableController?.states || {};
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
return html`
<div
class="edgeless-templates-panel"
data-app-theme=${appTheme}
style=${styleMap({
opacity: this.isDragging ? '0' : '1',
transition: 'opacity 0.2s',
})}
>
<div class="search-bar">
<input
class="search-input"
type="text"
placeholder="Search file or anything..."
@input=${this._updateSearchKeyword}
@cut=${stopPropagation}
@copy=${stopPropagation}
@paste=${stopPropagation}
/>
</div>
<div class="template-categories">
${repeat(
_categories,
cate => cate,
cate => {
return html`<div
class="category-entry ${_currentCategory === cate
? 'selected'
: ''}"
@click=${() => {
this._currentCategory = cate;
this._updateTemplates();
}}
>
${cate}
</div>`;
}
)}
</div>
<div class="template-viewport">
<div class="template-scrollcontent" data-scrollable>
<div class="template-list">
${this._loading
? html`<affine-template-loading
style=${styleMap({
position: 'absolute',
left: '50%',
top: '50%',
})}
></affine-template-loading>`
: repeat(
_templates,
template => template.name,
template => {
const preview = template.preview
? template.preview.startsWith('<svg')
? html`${unsafeSVG(template.preview)}`
: html`<img
src="${template.preview}"
class="template-preview"
loading="lazy"
/>`
: defaultPreview;
const isBeingDragged =
draggingElement &&
draggingElement.data.name === template.name;
return html`
<div
class=${`template-item ${
template === this._loadingTemplate ? 'loading' : ''
}`}
style=${styleMap({
opacity: isBeingDragged ? '0' : '1',
})}
data-hover-text="Add"
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
data: template,
preview,
})}
@touchstart=${(e: TouchEvent) => {
this.draggableController.onTouchStart(e, {
data: template,
preview,
});
}}
>
${preview}
${template === this._loadingTemplate
? html`<affine-template-loading></affine-template-loading>`
: nothing}
${template.name
? html`<affine-tooltip
.offset=${12}
.autoHide=${true}
tip-position="top"
>
${template.name}
</affine-tooltip>`
: nothing}
</div>
`;
}
)}
</div>
</div>
<overlay-scrollbar></overlay-scrollbar>
</div>
<div class="arrow">${Triangle}</div>
</div>
`;
}
@state()
private accessor _categories: string[] = [];
@state()
private accessor _currentCategory = '';
@state()
private accessor _loading = false;
@state()
private accessor _loadingTemplate: Template | null = null;
@state()
private accessor _searchKeyword = '';
@state()
private accessor _templates: Template[] = [];
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@state()
accessor isDragging = false;
}

View File

@@ -0,0 +1,220 @@
import { ArrowDownSmallIcon } from '@blocksuite/affine-components/icons';
import { once } from '@blocksuite/affine-shared/utils';
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
import type { GfxToolsFullOptionValue } from '@blocksuite/std/gfx';
import {
arrow,
autoUpdate,
computePosition,
offset,
shift,
} from '@floating-ui/dom';
import { css, html, LitElement } from 'lit';
import { state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { TemplateCard1, TemplateCard2, TemplateCard3 } from './cards.js';
import type { EdgelessTemplatePanel } from './template-panel.js';
export class EdgelessTemplateButton extends EdgelessToolbarToolMixin(
LitElement
) {
static override styles = css`
:host {
position: relative;
width: 100%;
height: 100%;
}
edgeless-template-button {
cursor: pointer;
}
.template-cards {
width: 100%;
height: 64px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.template-card,
.arrow-icon {
--x: 0;
--y: 0;
--r: 0;
--s: 1;
position: absolute;
transform: translate(var(--x), var(--y)) rotate(var(--r)) scale(var(--s));
transition: all 0.3s ease;
}
.arrow-icon {
--y: 17px;
background: var(--affine-black-10);
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.arrow-icon > svg {
color: var(--affine-icon-color);
fill: currentColor;
width: 20px;
height: 20px;
}
.template-card.card1 {
transform-origin: 100% 50%;
--x: 15px;
--y: 8px;
}
.template-card.card2 {
transform-origin: 0% 50%;
--x: -17px;
}
.template-card.card3 {
--y: 27px;
}
/* hover */
.template-cards:not(.expanded):hover .card1 {
--r: 8.69deg;
}
.template-cards:not(.expanded):hover .card2 {
--r: -10.93deg;
}
.template-cards:not(.expanded):hover .card3 {
--y: 22px;
--r: 5.19deg;
}
/* expanded */
.template-cards.expanded .card1 {
--x: 17px;
--y: -5px;
--r: 8.69deg;
--s: 0.64;
}
.template-cards.expanded .card2 {
--x: -19px;
--y: -6px;
--r: -10.93deg;
--s: 0.64;
}
.template-cards.expanded .card3 {
--y: -10px;
--s: 0.599;
--r: 5.19deg;
}
`;
private _cleanup: (() => void) | null = null;
private _prevTool: GfxToolsFullOptionValue | null = null;
override enableActiveBackground = true;
override type: GfxToolsFullOptionValue['type'] = 'template';
get cards() {
const { theme } = this;
return [TemplateCard1[theme], TemplateCard2[theme], TemplateCard3[theme]];
}
private _closePanel() {
if (this._openedPanel) {
this._openedPanel.remove();
this._openedPanel = null;
this._cleanup?.();
this._cleanup = null;
this.requestUpdate();
if (this._prevTool && this._prevTool.type !== 'template') {
this.setEdgelessTool(this._prevTool);
this._prevTool = null;
} else {
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool('default');
}
}
}
private _togglePanel() {
if (this._openedPanel) {
this._closePanel();
if (this._prevTool) {
this.setEdgelessTool(this._prevTool);
this._prevTool = null;
}
return;
}
this._prevTool = this.edgelessTool ? { ...this.edgelessTool } : null;
this.setEdgelessTool('template');
const panel = document.createElement('edgeless-templates-panel');
panel.edgeless = this.edgeless;
this._cleanup = once(panel, 'closepanel', () => {
this._closePanel();
});
this._openedPanel = panel;
this.renderRoot.append(panel);
requestAnimationFrame(() => {
const arrowEl = panel.renderRoot.querySelector('.arrow') as HTMLElement;
autoUpdate(this, panel, () => {
computePosition(this, panel, {
placement: 'top',
middleware: [offset(20), arrow({ element: arrowEl }), shift()],
})
.then(({ x, y, middlewareData }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
arrowEl.style.left = `${
(middlewareData.arrow?.x ?? 0) - (middlewareData.shift?.x ?? 0)
}px`;
})
.catch(e => {
console.warn("Can't compute position", e);
});
});
});
}
override render() {
const { cards, _openedPanel } = this;
const expanded = _openedPanel !== null;
return html`<edgeless-toolbar-button @click=${this._togglePanel}>
<div class="template-cards ${expanded ? 'expanded' : ''}">
<div class="arrow-icon">${ArrowDownSmallIcon}</div>
${repeat(
cards,
(card, n) => html`
<div
class=${classMap({
'template-card': true,
[`card${n + 1}`]: true,
})}
>
${card}
</div>
`
)}
</div>
</edgeless-toolbar-button>`;
}
@state()
private accessor _openedPanel: EdgelessTemplatePanel | null = null;
}

View File

@@ -0,0 +1,42 @@
export type Template = {
/**
* name of the sticker
*
* if not provided, it cannot be searched
*/
name?: string;
/**
* template content
*/
content: unknown;
/**
* external assets
*/
assets?: Record<string, string>;
preview?: string;
/**
* type of template
* `template`: normal template, looks like an article
* `sticker`: sticker template, only contains one image block under surface block
*/
type: 'template' | 'sticker';
};
export type TemplateCategory = {
name: string;
templates: Template[] | (() => Promise<Template[]>);
};
export interface TemplateManager {
list(category: string): Promise<Template[]> | Template[];
categories(): Promise<string[]> | string[];
search(keyword: string, category?: string): Promise<Template[]> | Template[];
extend?(manager: TemplateManager): void;
}

View File

@@ -0,0 +1,33 @@
import { on } from '@blocksuite/affine-shared/utils';
export function onClickOutside(target: HTMLElement, fn: () => void) {
return on(document, 'click', (evt: MouseEvent) => {
if (target.contains(evt.target as Node)) return;
fn();
return;
});
}
export function cloneDeep<T>(obj: T): T {
const seen = new WeakMap();
const clone = (val: unknown) => {
if (typeof val !== 'object' || val === null) return val;
if (seen.has(val)) return seen.get(val);
const copy = Array.isArray(val) ? [] : {};
seen.set(val, copy);
Object.keys(val).forEach(key => {
// @ts-expect-error deep clone
copy[key] = clone(val[key]);
});
return copy;
};
return clone(obj);
}