mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 21:48:48 +08:00
feat(editor): gfx template package (#11480)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user