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