snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
import { getAttachmentFileIcon } from '@blocksuite/affine/components/icons';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine-model';
|
||||
import { formatSize } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
ArrowDownBigIcon,
|
||||
ArrowUpBigIcon,
|
||||
CloseIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html, LitElement, type TemplateResult } from 'lit';
|
||||
import { customElement, query } from 'lit/decorators.js';
|
||||
|
||||
import type { DocInfo, MessageData, MessageDataType } from './pdf/types.js';
|
||||
import { MessageOp, RenderKind, State } from './pdf/types.js';
|
||||
|
||||
const DPI = window.devicePixelRatio;
|
||||
|
||||
type FileInfo = {
|
||||
name: string;
|
||||
size: string | null;
|
||||
isPDF: boolean;
|
||||
icon: TemplateResult;
|
||||
};
|
||||
|
||||
@customElement('attachment-viewer-panel')
|
||||
export class AttachmentViewerPanel extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
dialog {
|
||||
padding: 0;
|
||||
top: 50px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--affine-v2-dialog-background-primary);
|
||||
box-shadow: var(--affine-overlay-shadow);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 700px;
|
||||
height: 900px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
|
||||
& > .close {
|
||||
user-select: none;
|
||||
outline: none;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
header,
|
||||
footer {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
h5 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin: 0;
|
||||
|
||||
.file-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
|
||||
.page {
|
||||
width: calc(100% - 40px);
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
readonly #cursor = signal<number>(0);
|
||||
|
||||
readonly #docInfo = signal<DocInfo | null>(null);
|
||||
|
||||
readonly #fileInfo = signal<FileInfo | null>(null);
|
||||
|
||||
readonly #state = signal<State>(State.Connecting);
|
||||
|
||||
#worker: Worker | null = null;
|
||||
|
||||
clear = () => {
|
||||
this.#dialog.close();
|
||||
|
||||
this.#state.value = State.IDLE;
|
||||
this.#worker?.terminate();
|
||||
this.#worker = null;
|
||||
|
||||
this.#fileInfo.value = null;
|
||||
this.#docInfo.value = null;
|
||||
this.#cursor.value = 0;
|
||||
|
||||
const canvas = this.#page;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
goto(at: number) {
|
||||
this.#cursor.value = at;
|
||||
this.post(MessageOp.Render, {
|
||||
index: at,
|
||||
scale: 1 * DPI,
|
||||
kind: RenderKind.Page,
|
||||
});
|
||||
}
|
||||
|
||||
open(model: AttachmentBlockModel) {
|
||||
this.#dialog.showModal();
|
||||
|
||||
const { name, size } = model.props;
|
||||
|
||||
const fileType = name.split('.').pop() ?? '';
|
||||
const icon = getAttachmentFileIcon(fileType);
|
||||
const isPDF = fileType === 'pdf';
|
||||
|
||||
this.#fileInfo.value = {
|
||||
name,
|
||||
icon,
|
||||
isPDF,
|
||||
size: formatSize(size),
|
||||
};
|
||||
|
||||
if (!isPDF) return;
|
||||
if (!model.props.sourceId) return;
|
||||
if (this.#worker) return;
|
||||
|
||||
const process = async ({ data }: MessageEvent<MessageData>) => {
|
||||
const { type } = data;
|
||||
|
||||
switch (type) {
|
||||
case MessageOp.Init: {
|
||||
console.debug('connecting');
|
||||
this.#state.value = State.Connecting;
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageOp.Inited: {
|
||||
console.debug('connected');
|
||||
this.#state.value = State.Connected;
|
||||
|
||||
const blob = await model.store.blobSync.get(model.props.sourceId!);
|
||||
|
||||
if (!blob) return;
|
||||
const buffer = await blob.arrayBuffer();
|
||||
|
||||
this.post(MessageOp.Open, buffer, [buffer]);
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageOp.Opened: {
|
||||
const info = data[type];
|
||||
this.#cursor.value = 0;
|
||||
this.#docInfo.value = info;
|
||||
this.#state.value = State.Opened;
|
||||
this.post(MessageOp.Render, {
|
||||
index: 0,
|
||||
scale: 1 * DPI,
|
||||
kind: RenderKind.Page,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageOp.Rendered: {
|
||||
const { index, kind, imageData } = data[type];
|
||||
|
||||
if (index !== this.#cursor.value) return;
|
||||
|
||||
const canvas = this.#page;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
console.debug('render page', index, kind);
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
|
||||
ctx.clearRect(0, 0, imageData.width, imageData.height);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.#worker = new Worker(new URL('./pdf/worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
this.#worker.addEventListener('message', event => {
|
||||
process(event).catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
post<T extends MessageOp>(
|
||||
type: T,
|
||||
data?: MessageDataType[T],
|
||||
transfers?: Transferable[]
|
||||
) {
|
||||
if (!this.#worker) return;
|
||||
|
||||
const message = { type, [type]: data };
|
||||
if (transfers?.length) {
|
||||
this.#worker?.postMessage(message, transfers);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#worker?.postMessage(message);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const fileInfo = this.#fileInfo.value;
|
||||
const isPDF = fileInfo?.isPDF ?? false;
|
||||
const docInfo = this.#docInfo.value;
|
||||
const cursor = this.#cursor.value;
|
||||
const total = docInfo ? docInfo.total : 0;
|
||||
const width = docInfo ? docInfo.width : 0;
|
||||
const height = docInfo ? docInfo.height : 0;
|
||||
const isEmpty = total === 0;
|
||||
const print = (n: number) => (isEmpty ? '-' : n);
|
||||
|
||||
return html`
|
||||
<dialog>
|
||||
<div class="dialog">
|
||||
<header>
|
||||
<h5>
|
||||
<span>${fileInfo?.name}</span>
|
||||
<span>${fileInfo?.size}</span>
|
||||
<span class="file-icon">${fileInfo?.icon}</span>
|
||||
</h5>
|
||||
</header>
|
||||
<main class="body">
|
||||
${
|
||||
isPDF
|
||||
? html`<canvas class="page"></canvas>`
|
||||
: html`<p class="error">This file format is not supported.</p>`
|
||||
}
|
||||
<div class="controls">
|
||||
<icon-button
|
||||
.disabled=${isEmpty || cursor === 0}
|
||||
@click=${() => this.goto(cursor - 1)}
|
||||
>${ArrowUpBigIcon()}</icon-button
|
||||
>
|
||||
<icon-button
|
||||
.disabled=${isEmpty || cursor + 1 === total}
|
||||
@click=${() => this.goto(cursor + 1)}
|
||||
>${ArrowDownBigIcon()}</icon-button
|
||||
>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<div>
|
||||
<span>${print(width)}</span>
|
||||
x
|
||||
<span>${print(height)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>${print(cursor + 1)}</span>
|
||||
/
|
||||
<span>${print(total)}</span>
|
||||
</div>
|
||||
</footer>
|
||||
<icon-button class="close" @click=${this.clear}
|
||||
>${CloseIcon()}</icon-button
|
||||
>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('dialog')
|
||||
accessor #dialog!: HTMLDialogElement;
|
||||
|
||||
@query('.page')
|
||||
accessor #page: HTMLCanvasElement | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'attachment-viewer-panel': AttachmentViewerPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import type { TransformerMiddleware } from '@blocksuite/affine/store';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
@customElement('custom-adapter-panel')
|
||||
export class CustomAdapterPanel extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.custom-adapter-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 16px;
|
||||
border: 1px solid var(--affine-border-color, #e3e2e4);
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
height: 100vh;
|
||||
width: 30vw;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
private _renderPanel() {
|
||||
return html`<affine-adapter-panel
|
||||
.store=${this.editor.doc}
|
||||
.transformerMiddlewares=${this.transformerMiddlewares}
|
||||
></affine-adapter-panel>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${
|
||||
this._show
|
||||
? html`
|
||||
<div class="custom-adapter-container">${this._renderPanel()}</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
toggleDisplay() {
|
||||
this._show = !this._show;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _show = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: TestAffineEditorContainer;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor transformerMiddlewares: TransformerMiddleware[] = [];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'custom-adapter-panel': CustomAdapterPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
@customElement('custom-frame-panel')
|
||||
export class CustomFramePanel extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.custom-frame-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: 1px solid var(--affine-border-color, #e3e2e4);
|
||||
background-color: var(--affine-background-primary-color);
|
||||
height: 100vh;
|
||||
width: 320px;
|
||||
box-sizing: border-box;
|
||||
padding-top: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
private _renderPanel() {
|
||||
return html`<affine-frame-panel
|
||||
.host=${this.editor.std.host}
|
||||
></affine-frame-panel>`;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const std = this.editor.std;
|
||||
if (std) {
|
||||
this.editor.updateComplete
|
||||
.then(() => this.requestUpdate())
|
||||
.catch(console.error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${
|
||||
this._show
|
||||
? html`<div class="custom-frame-container">
|
||||
${this._renderPanel()}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
toggleDisplay() {
|
||||
this._show = !this._show;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _show = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: TestAffineEditorContainer;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'custom-frame-panel': CustomFramePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
@customElement('custom-outline-panel')
|
||||
export class CustomOutlinePanel extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.custom-outline-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 16px;
|
||||
border: 1px solid var(--affine-border-color, #e3e2e4);
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
height: 100vh;
|
||||
width: 320px;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
private _renderPanel() {
|
||||
return html`<affine-outline-panel
|
||||
.editor=${this.editor.host}
|
||||
.fitPadding=${[50, 360, 50, 50]}
|
||||
></affine-outline-panel>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${
|
||||
this._show
|
||||
? html`
|
||||
<div class="custom-outline-container">${this._renderPanel()}</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
toggleDisplay() {
|
||||
this._show = !this._show;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _show = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: TestAffineEditorContainer;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'custom-outline-panel': CustomOutlinePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
@customElement('custom-outline-viewer')
|
||||
export class CustomOutlineViewer extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.outline-viewer-container {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
top: 256px;
|
||||
right: 22px;
|
||||
max-height: calc(100vh - 256px - 76px); // top(256px) and bottom(76px)
|
||||
}
|
||||
`;
|
||||
|
||||
private _renderViewer() {
|
||||
return html`<affine-outline-viewer
|
||||
.editor=${this.editor.host}
|
||||
.toggleOutlinePanel=${this.toggleOutlinePanel}
|
||||
></affine-outline-viewer>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this._show || this.editor.mode === 'edgeless') return nothing;
|
||||
|
||||
return html`<div class="outline-viewer-container">
|
||||
${this._renderViewer()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
toggleDisplay() {
|
||||
this._show = !this._show;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _show = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: TestAffineEditorContainer;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggleOutlinePanel: (() => void) | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'custom-outline-viewer': CustomOutlineViewer;
|
||||
}
|
||||
}
|
||||
181
blocksuite/playground/apps/_common/components/docs-panel.ts
Normal file
181
blocksuite/playground/apps/_common/components/docs-panel.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { GenerateDocUrlProvider } from '@blocksuite/affine/shared/services';
|
||||
import { createDefaultDoc } from '@blocksuite/affine/shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import type { Doc, Workspace } from '@blocksuite/affine/store';
|
||||
import { CloseIcon } from '@blocksuite/icons/lit';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { removeModeFromStorage } from '../mock-services.js';
|
||||
|
||||
@customElement('docs-panel')
|
||||
export class DocsPanel extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
docs-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
background-color: var(--affine-background-secondary-color);
|
||||
font-family: var(--affine-font-family);
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
.doc-item:hover .delete-doc-icon {
|
||||
display: flex;
|
||||
}
|
||||
.doc-item {
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
.delete-doc-icon {
|
||||
display: none;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.delete-doc-icon:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
.delete-doc-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--affine-secondary-color);
|
||||
fill: var(--affine-secondary-color);
|
||||
}
|
||||
.new-doc-button {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
.new-doc-button:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
createDoc = () => {
|
||||
createDocBlock(this.editor.doc.workspace);
|
||||
};
|
||||
|
||||
gotoDoc = (doc: Doc) => {
|
||||
const url = this.editor.std
|
||||
.getOptional(GenerateDocUrlProvider)
|
||||
?.generateDocUrl(doc.id);
|
||||
if (url) history.pushState({}, '', url);
|
||||
|
||||
this.editor.doc = doc.getStore();
|
||||
this.editor.doc.load();
|
||||
this.editor.doc.resetHistory();
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private get collection() {
|
||||
return this.editor.doc.workspace;
|
||||
}
|
||||
|
||||
private get docs() {
|
||||
return [...this.collection.docs.values()];
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!(event.target instanceof Node)) return;
|
||||
|
||||
const toggleButton = document.querySelector(
|
||||
'sl-button[data-docs-panel-toggle]'
|
||||
);
|
||||
if (toggleButton?.contains(event.target as Node)) return;
|
||||
|
||||
if (!this.contains(event.target)) {
|
||||
this.onClose?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
this.disposables.add(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
this.editor.doc.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
const { docs, collection } = this;
|
||||
return html`
|
||||
<div @click="${this.createDoc}" class="new-doc-button">New Doc</div>
|
||||
${repeat(
|
||||
docs,
|
||||
v => v.id,
|
||||
doc => {
|
||||
const style = styleMap({
|
||||
backgroundColor:
|
||||
this.editor.doc.id === doc.id
|
||||
? 'var(--affine-hover-color)'
|
||||
: undefined,
|
||||
padding: '4px 4px 4px 8px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
const click = () => {
|
||||
this.gotoDoc(doc);
|
||||
};
|
||||
const deleteDoc = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const isDeleteCurrent = doc.id === this.editor.doc.id;
|
||||
|
||||
collection.removeDoc(doc.id);
|
||||
removeModeFromStorage(doc.id);
|
||||
// When delete the current doc, we need to set the editor doc to the first remaining doc
|
||||
if (isDeleteCurrent) {
|
||||
this.editor.doc = this.docs[0].getStore();
|
||||
}
|
||||
};
|
||||
return html`<div class="doc-item" @click="${click}" style="${style}">
|
||||
${doc.meta?.title || 'Untitled'}
|
||||
${
|
||||
docs.length > 1
|
||||
? html`<div @click="${deleteDoc}" class="delete-doc-icon">
|
||||
${CloseIcon()}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: TestAffineEditorContainer;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onClose!: () => void;
|
||||
}
|
||||
|
||||
function createDocBlock(collection: Workspace) {
|
||||
const id = collection.idGenerator();
|
||||
createDefaultDoc(collection, { id });
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'docs-panel': DocsPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { css, html } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
@customElement('left-side-panel')
|
||||
export class LeftSidePanel extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
left-side-panel {
|
||||
padding-top: 50px;
|
||||
width: 300px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
currentContent: HTMLElement | null = null;
|
||||
|
||||
hideContent() {
|
||||
if (this.currentContent) {
|
||||
this.style.display = 'none';
|
||||
this.currentContent.remove();
|
||||
this.currentContent = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
return html``;
|
||||
}
|
||||
|
||||
showContent(ele: HTMLElement) {
|
||||
if (this.currentContent) {
|
||||
this.currentContent.remove();
|
||||
}
|
||||
this.style.display = 'block';
|
||||
this.currentContent = ele;
|
||||
this.append(ele);
|
||||
}
|
||||
|
||||
toggle(ele: HTMLElement) {
|
||||
if (this.currentContent !== ele) {
|
||||
this.showContent(ele);
|
||||
} else {
|
||||
this.hideContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'left-side-panel': LeftSidePanel;
|
||||
}
|
||||
}
|
||||
64
blocksuite/playground/apps/_common/components/pdf/types.ts
Normal file
64
blocksuite/playground/apps/_common/components/pdf/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export enum State {
|
||||
IDLE = 0,
|
||||
Connecting,
|
||||
Connected,
|
||||
Opening,
|
||||
Opened,
|
||||
Failed,
|
||||
}
|
||||
|
||||
export type DocInfo = {
|
||||
total: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ViewportInfo = {
|
||||
dpi: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export enum MessageState {
|
||||
Poll,
|
||||
Ready,
|
||||
}
|
||||
|
||||
export enum MessageOp {
|
||||
Init,
|
||||
Inited,
|
||||
Open,
|
||||
Opened,
|
||||
Render,
|
||||
Rendered,
|
||||
}
|
||||
|
||||
export enum RenderKind {
|
||||
Page,
|
||||
Thumbnail,
|
||||
}
|
||||
|
||||
export interface MessageDataMap {
|
||||
[MessageOp.Init]: undefined;
|
||||
[MessageOp.Inited]: undefined;
|
||||
[MessageOp.Open]: ArrayBuffer;
|
||||
[MessageOp.Opened]: DocInfo;
|
||||
[MessageOp.Render]: {
|
||||
index: number;
|
||||
kind: RenderKind;
|
||||
scale: number;
|
||||
};
|
||||
[MessageOp.Rendered]: {
|
||||
index: number;
|
||||
kind: RenderKind;
|
||||
imageData: ImageData;
|
||||
};
|
||||
}
|
||||
|
||||
export type MessageDataType<T = MessageDataMap> = {
|
||||
[P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
export type MessageData<T = MessageOp, P = MessageDataType> = {
|
||||
type: T;
|
||||
} & P;
|
||||
123
blocksuite/playground/apps/_common/components/pdf/worker.ts
Normal file
123
blocksuite/playground/apps/_common/components/pdf/worker.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Document } from '@toeverything/pdf-viewer';
|
||||
import {
|
||||
createPDFium,
|
||||
PageRenderingflags,
|
||||
Runtime,
|
||||
Viewer,
|
||||
} from '@toeverything/pdf-viewer';
|
||||
import wasmUrl from '@toeverything/pdfium/wasm?url';
|
||||
|
||||
import { type MessageData, type MessageDataType, MessageOp } from './types';
|
||||
|
||||
let inited = false;
|
||||
let viewer: Viewer | null = null;
|
||||
let doc: Document | undefined = undefined;
|
||||
|
||||
const docInfo = { total: 0, width: 1, height: 1 };
|
||||
const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT;
|
||||
|
||||
function post<T extends MessageOp>(type: T, data?: MessageDataType[T]) {
|
||||
const message = { type, [type]: data };
|
||||
self.postMessage(message);
|
||||
}
|
||||
|
||||
function renderToImageData(index: number, scale: number) {
|
||||
if (!viewer || !doc) return;
|
||||
|
||||
const page = doc.page(index);
|
||||
|
||||
if (!page) return;
|
||||
|
||||
const width = Math.ceil(docInfo.width * scale);
|
||||
const height = Math.ceil(docInfo.height * scale);
|
||||
|
||||
const bitmap = viewer.createBitmap(width, height, 0);
|
||||
bitmap.fill(0, 0, width, height);
|
||||
page.render(bitmap, 0, 0, width, height, 0, flags);
|
||||
|
||||
const data = new Uint8ClampedArray(bitmap.toUint8Array());
|
||||
|
||||
bitmap.close();
|
||||
page.close();
|
||||
|
||||
return new ImageData(data, width, height);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
inited = true;
|
||||
|
||||
console.debug('pdf worker pending');
|
||||
self.postMessage({ type: MessageOp.Init });
|
||||
|
||||
const pdfium = await createPDFium({
|
||||
// @ts-expect-error allow
|
||||
locateFile: () => wasmUrl,
|
||||
});
|
||||
viewer = new Viewer(new Runtime(pdfium));
|
||||
|
||||
self.postMessage({ type: MessageOp.Inited });
|
||||
console.debug('pdf worker ready');
|
||||
}
|
||||
|
||||
async function process({ data }: MessageEvent<MessageData>) {
|
||||
if (!inited) {
|
||||
await start();
|
||||
}
|
||||
|
||||
if (!viewer) return;
|
||||
|
||||
const { type } = data;
|
||||
|
||||
switch (type) {
|
||||
case MessageOp.Open: {
|
||||
const buffer = data[type];
|
||||
if (!buffer) return;
|
||||
|
||||
doc = viewer.open(new Uint8Array(buffer));
|
||||
|
||||
if (!doc) return;
|
||||
|
||||
const page = doc.page(0);
|
||||
|
||||
if (!page) return;
|
||||
|
||||
Object.assign(docInfo, {
|
||||
total: doc.pageCount(),
|
||||
height: Math.ceil(page.height()),
|
||||
width: Math.ceil(page.width()),
|
||||
});
|
||||
page.close();
|
||||
post(MessageOp.Opened, docInfo);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageOp.Render: {
|
||||
if (!doc) return;
|
||||
|
||||
const { index, kind, scale } = data[type];
|
||||
|
||||
const { total } = docInfo;
|
||||
|
||||
if (index < 0 || index >= total) return;
|
||||
|
||||
queueMicrotask(() => {
|
||||
const imageData = renderToImageData(index, scale);
|
||||
if (!imageData) return;
|
||||
|
||||
post(MessageOp.Rendered, { index, kind, imageData });
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('message', (event: MessageEvent<MessageData>) => {
|
||||
process(event).catch(console.error);
|
||||
});
|
||||
|
||||
start().catch(error => {
|
||||
inited = false;
|
||||
console.log(error);
|
||||
});
|
||||
1057
blocksuite/playground/apps/_common/components/starter-debug-menu.ts
Normal file
1057
blocksuite/playground/apps/_common/components/starter-debug-menu.ts
Normal file
File diff suppressed because it is too large
Load Diff
22
blocksuite/playground/apps/_common/helper.ts
Normal file
22
blocksuite/playground/apps/_common/helper.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { TestWorkspace } from '@blocksuite/affine/store/test';
|
||||
import { getTestStoreManager } from '@blocksuite/integration-test/store';
|
||||
|
||||
export function createEmptyDoc() {
|
||||
const collection = new TestWorkspace();
|
||||
collection.storeExtensions = getTestStoreManager().get('store');
|
||||
collection.meta.initialize();
|
||||
const doc = collection.createDoc();
|
||||
const store = doc.getStore();
|
||||
|
||||
return {
|
||||
doc,
|
||||
init() {
|
||||
doc.load();
|
||||
const rootId = store.addBlock('affine:page', {});
|
||||
store.addBlock('affine:surface', {}, rootId);
|
||||
const noteId = store.addBlock('affine:note', {}, rootId);
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
return store;
|
||||
},
|
||||
};
|
||||
}
|
||||
62
blocksuite/playground/apps/_common/history.ts
Normal file
62
blocksuite/playground/apps/_common/history.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { DocModeProvider } from '@blocksuite/affine/shared/services';
|
||||
import type { Doc, Store, Workspace } from '@blocksuite/affine/store';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
|
||||
export function getDocFromUrlParams(collection: Workspace, url: URL) {
|
||||
let doc: Store | null = null;
|
||||
|
||||
const docId = decodeURIComponent(url.hash.slice(1));
|
||||
|
||||
if (docId) {
|
||||
doc = collection.getDoc(docId)?.getStore() ?? null;
|
||||
}
|
||||
if (!doc) {
|
||||
const blockCollection = collection.docs.values().next().value as Doc;
|
||||
if (!blockCollection) {
|
||||
throw new Error('Need to create a doc first');
|
||||
}
|
||||
doc = blockCollection.getStore();
|
||||
}
|
||||
|
||||
doc.load();
|
||||
doc.resetHistory();
|
||||
|
||||
if (!doc.root) {
|
||||
throw new Error('Doc root is not ready');
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function setDocModeFromUrlParams(
|
||||
service: DocModeProvider,
|
||||
search: URLSearchParams,
|
||||
docId: string
|
||||
) {
|
||||
const paramMode = search.get('mode');
|
||||
if (paramMode) {
|
||||
const docMode = paramMode === 'page' ? 'page' : 'edgeless';
|
||||
service.setPrimaryMode(docMode, docId);
|
||||
service.setEditorMode(docMode);
|
||||
}
|
||||
}
|
||||
|
||||
export function listenHashChange(
|
||||
collection: Workspace,
|
||||
editor: TestAffineEditorContainer
|
||||
) {
|
||||
const panel = document.querySelector('docs-panel');
|
||||
window.addEventListener('hashchange', () => {
|
||||
const url = new URL(location.toString());
|
||||
const doc = getDocFromUrlParams(collection, url);
|
||||
if (!doc) return;
|
||||
|
||||
if (panel?.checkVisibility()) {
|
||||
panel.requestUpdate();
|
||||
}
|
||||
|
||||
editor.doc = doc;
|
||||
editor.doc.load();
|
||||
editor.doc.resetHistory();
|
||||
});
|
||||
}
|
||||
286
blocksuite/playground/apps/_common/mock-services.ts
Normal file
286
blocksuite/playground/apps/_common/mock-services.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { toast } from '@blocksuite/affine/components/toast';
|
||||
import {
|
||||
ColorScheme,
|
||||
type DocMode,
|
||||
type ReferenceParams,
|
||||
} from '@blocksuite/affine/model';
|
||||
import {
|
||||
type CommentId,
|
||||
type CommentProvider,
|
||||
type DocModeProvider,
|
||||
type EditorSetting,
|
||||
GeneralSettingSchema,
|
||||
type GenerateDocUrlService,
|
||||
type NotificationService,
|
||||
type ParseDocUrlService,
|
||||
type ThemeExtension,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import type { BaseSelection, Workspace } from '@blocksuite/affine/store';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import { Signal, signal } from '@preact/signals-core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
function getModeFromStorage() {
|
||||
const mapJson = localStorage.getItem('playground:docMode');
|
||||
const mapArray = mapJson ? JSON.parse(mapJson) : [];
|
||||
return new Map<string, DocMode>(mapArray);
|
||||
}
|
||||
|
||||
function saveModeToStorage(map: Map<string, DocMode>) {
|
||||
const mapArray = Array.from(map);
|
||||
const mapJson = JSON.stringify(mapArray);
|
||||
localStorage.setItem('playground:docMode', mapJson);
|
||||
}
|
||||
|
||||
export function removeModeFromStorage(docId: string) {
|
||||
const modeMap = getModeFromStorage();
|
||||
modeMap.delete(docId);
|
||||
saveModeToStorage(modeMap);
|
||||
}
|
||||
|
||||
const DEFAULT_MODE: DocMode = 'page';
|
||||
const slotMap = new Map<string, Subject<DocMode>>();
|
||||
|
||||
export function mockDocModeService(editor: TestAffineEditorContainer) {
|
||||
const getEditorModeCallback: () => DocMode = () => editor.mode;
|
||||
const setEditorModeCallback: (mode: DocMode) => void = mode =>
|
||||
editor.switchEditor(mode);
|
||||
const docModeService: DocModeProvider = {
|
||||
getPrimaryMode: (docId: string) => {
|
||||
try {
|
||||
const modeMap = getModeFromStorage();
|
||||
return modeMap.get(docId) ?? DEFAULT_MODE;
|
||||
} catch {
|
||||
return DEFAULT_MODE;
|
||||
}
|
||||
},
|
||||
onPrimaryModeChange: (handler: (mode: DocMode) => void, docId: string) => {
|
||||
if (!slotMap.get(docId)) {
|
||||
slotMap.set(docId, new Subject());
|
||||
}
|
||||
return slotMap.get(docId)!.subscribe(handler);
|
||||
},
|
||||
getEditorMode: () => {
|
||||
return getEditorModeCallback();
|
||||
},
|
||||
setEditorMode: (mode: DocMode) => {
|
||||
setEditorModeCallback(mode);
|
||||
},
|
||||
setPrimaryMode: (mode: DocMode, docId: string) => {
|
||||
const modeMap = getModeFromStorage();
|
||||
modeMap.set(docId, mode);
|
||||
saveModeToStorage(modeMap);
|
||||
slotMap.get(docId)?.next(mode);
|
||||
},
|
||||
togglePrimaryMode: (docId: string) => {
|
||||
const mode =
|
||||
docModeService.getPrimaryMode(docId) === 'page' ? 'edgeless' : 'page';
|
||||
docModeService.setPrimaryMode(mode, docId);
|
||||
return mode;
|
||||
},
|
||||
};
|
||||
return docModeService;
|
||||
}
|
||||
|
||||
export function mockNotificationService(editor: TestAffineEditorContainer) {
|
||||
const notificationService: NotificationService = {
|
||||
toast: (message, options) => {
|
||||
toast(editor.host!, message, options?.duration);
|
||||
},
|
||||
confirm: notification => {
|
||||
return Promise.resolve(confirm(notification.title.toString()));
|
||||
},
|
||||
prompt: notification => {
|
||||
return Promise.resolve(
|
||||
prompt(notification.title.toString(), notification.autofill?.toString())
|
||||
);
|
||||
},
|
||||
notify: notification => {
|
||||
// todo: implement in playground
|
||||
console.log(notification);
|
||||
},
|
||||
notifyWithUndoAction: notification => {
|
||||
// todo: implement in playground
|
||||
console.log(notification);
|
||||
},
|
||||
};
|
||||
return notificationService;
|
||||
}
|
||||
|
||||
export function mockParseDocUrlService(collection: Workspace) {
|
||||
const parseDocUrlService: ParseDocUrlService = {
|
||||
parseDocUrl: (url: string) => {
|
||||
if (url && URL.canParse(url)) {
|
||||
const path = decodeURIComponent(new URL(url).hash.slice(1));
|
||||
const item =
|
||||
path.length > 0
|
||||
? Array.from(collection.docs.values()).find(doc => doc.id === path)
|
||||
: null;
|
||||
if (item) {
|
||||
return {
|
||||
docId: item.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
return parseDocUrlService;
|
||||
}
|
||||
|
||||
export class MockEdgelessTheme {
|
||||
theme$ = signal(ColorScheme.Light);
|
||||
|
||||
setTheme(theme: ColorScheme) {
|
||||
this.theme$.value = theme;
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const theme =
|
||||
this.theme$.value === ColorScheme.Dark
|
||||
? ColorScheme.Light
|
||||
: ColorScheme.Dark;
|
||||
this.theme$.value = theme;
|
||||
}
|
||||
}
|
||||
|
||||
export const mockEdgelessTheme = new MockEdgelessTheme();
|
||||
|
||||
export const themeExtension: ThemeExtension = {
|
||||
getEdgelessTheme() {
|
||||
return mockEdgelessTheme.theme$;
|
||||
},
|
||||
};
|
||||
|
||||
export function mockGenerateDocUrlService(collection: Workspace) {
|
||||
const generateDocUrlService: GenerateDocUrlService = {
|
||||
generateDocUrl: (docId: string, params?: ReferenceParams) => {
|
||||
const doc = collection.getDoc(docId);
|
||||
if (!doc) return;
|
||||
|
||||
const url = new URL(location.pathname, location.origin);
|
||||
url.search = location.search;
|
||||
if (params) {
|
||||
const search = url.searchParams;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
search.set(key, Array.isArray(value) ? value.join(',') : value);
|
||||
}
|
||||
}
|
||||
url.hash = encodeURIComponent(docId);
|
||||
|
||||
return url.toString();
|
||||
},
|
||||
};
|
||||
return generateDocUrlService;
|
||||
}
|
||||
|
||||
export function mockEditorSetting() {
|
||||
if (window.editorSetting$) return window.editorSetting$;
|
||||
|
||||
const initialVal = Object.entries(GeneralSettingSchema.shape).reduce(
|
||||
(pre: EditorSetting, [key, schema]) => {
|
||||
// @ts-expect-error key is EditorSetting field
|
||||
pre[key as keyof EditorSetting] = schema.parse(undefined);
|
||||
return pre;
|
||||
},
|
||||
{} as EditorSetting
|
||||
);
|
||||
|
||||
const signal = new Signal<EditorSetting>(initialVal);
|
||||
|
||||
window.editorSetting$ = signal;
|
||||
|
||||
return signal;
|
||||
}
|
||||
|
||||
export function mockCommentProvider() {
|
||||
class MockCommentProvider implements CommentProvider {
|
||||
commentId = 0;
|
||||
|
||||
comments = new Map<
|
||||
CommentId,
|
||||
{
|
||||
selections: BaseSelection[];
|
||||
resolved: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
commentAddSubject = new Subject<{
|
||||
id: CommentId;
|
||||
selections: BaseSelection[];
|
||||
}>();
|
||||
|
||||
commentResolveSubject = new Subject<CommentId>();
|
||||
|
||||
commentHighlightSubject = new Subject<CommentId | null>();
|
||||
|
||||
commentDeleteSubject = new Subject<CommentId>();
|
||||
|
||||
addComment(selections: BaseSelection[]) {
|
||||
const id: CommentId = `${this.commentId++}`;
|
||||
this.comments.set(id, {
|
||||
selections,
|
||||
resolved: false,
|
||||
});
|
||||
this.commentAddSubject.next({
|
||||
id,
|
||||
selections,
|
||||
});
|
||||
}
|
||||
|
||||
resolveComment(id: CommentId) {
|
||||
const comment = this.comments.get(id);
|
||||
if (!comment) return;
|
||||
comment.resolved = true;
|
||||
this.commentResolveSubject.next(id);
|
||||
}
|
||||
|
||||
deleteComment(id: CommentId) {
|
||||
this.comments.delete(id);
|
||||
this.commentDeleteSubject.next(id);
|
||||
}
|
||||
|
||||
highlightComment(id: CommentId | null) {
|
||||
this.commentHighlightSubject.next(id);
|
||||
}
|
||||
|
||||
getComments(type: 'resolved' | 'unresolved' | 'all' = 'all') {
|
||||
return Array.from(this.comments.entries())
|
||||
.filter(([_, comment]) => {
|
||||
if (type === 'all') return true;
|
||||
if (type === 'resolved') return comment.resolved;
|
||||
return !comment.resolved;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
onCommentAdded(
|
||||
callback: (id: CommentId, selections: BaseSelection[]) => void
|
||||
) {
|
||||
return this.commentAddSubject.subscribe(({ id, selections }) => {
|
||||
callback(id, selections);
|
||||
});
|
||||
}
|
||||
|
||||
onCommentResolved(callback: (id: CommentId) => void) {
|
||||
return this.commentResolveSubject.subscribe(callback);
|
||||
}
|
||||
|
||||
onCommentDeleted(callback: (id: CommentId) => void) {
|
||||
return this.commentDeleteSubject.subscribe(callback);
|
||||
}
|
||||
|
||||
onCommentHighlighted(callback: (id: CommentId | null) => void) {
|
||||
return this.commentHighlightSubject.subscribe(callback);
|
||||
}
|
||||
}
|
||||
|
||||
const provider = new MockCommentProvider();
|
||||
return provider;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
editorSetting$: Signal<EditorSetting>;
|
||||
}
|
||||
}
|
||||
74
blocksuite/playground/apps/_common/setup.ts
Normal file
74
blocksuite/playground/apps/_common/setup.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
Template,
|
||||
TemplateCategory,
|
||||
TemplateManager,
|
||||
} from '@blocksuite/affine/gfx/template';
|
||||
import { EdgelessTemplatePanel } from '@blocksuite/affine/gfx/template';
|
||||
|
||||
export function setupEdgelessTemplate() {
|
||||
const playgroundTemplates = [
|
||||
{
|
||||
name: 'Paws and pals',
|
||||
templates: () =>
|
||||
import('./templates/stickers.js').then(module => module.default),
|
||||
},
|
||||
] as TemplateCategory[];
|
||||
|
||||
function lcs(text1: string, text2: string): number {
|
||||
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];
|
||||
}
|
||||
|
||||
EdgelessTemplatePanel.templates.extend({
|
||||
search: async (keyword: string) => {
|
||||
const candidates: Template[] = [];
|
||||
|
||||
await Promise.all(
|
||||
playgroundTemplates.map(async cate => {
|
||||
const templates =
|
||||
cate.templates instanceof Function
|
||||
? await cate.templates()
|
||||
: cate.templates;
|
||||
|
||||
templates.forEach(template => {
|
||||
if (
|
||||
template.name &&
|
||||
lcs(template.name, keyword) === keyword.length
|
||||
) {
|
||||
candidates.push(template);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return candidates;
|
||||
},
|
||||
list: async (cate: string) => {
|
||||
const category = playgroundTemplates.find(c => c.name === cate);
|
||||
|
||||
if (category?.templates instanceof Function) {
|
||||
return category.templates();
|
||||
}
|
||||
|
||||
return category?.templates ?? [];
|
||||
},
|
||||
|
||||
categories: () => playgroundTemplates.map(cate => cate.name),
|
||||
} satisfies TemplateManager);
|
||||
}
|
||||
129
blocksuite/playground/apps/_common/sync/blob/mock-server.ts
Normal file
129
blocksuite/playground/apps/_common/sync/blob/mock-server.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { BlobSource, BlobState } from '@blocksuite/sync';
|
||||
import { BehaviorSubject, ReplaySubject, share, throttleTime } from 'rxjs';
|
||||
|
||||
/**
|
||||
* @internal just for test
|
||||
*
|
||||
* API: /api/collection/:id/blob/:key
|
||||
* GET: get blob
|
||||
* PUT: set blob
|
||||
* DELETE: delete blob
|
||||
*/
|
||||
export class MockServerBlobSource implements BlobSource {
|
||||
private readonly _cache = new Map<string, Blob>();
|
||||
private readonly _states = new Map<string, BehaviorSubject<BlobState>>();
|
||||
|
||||
readonly = false;
|
||||
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
async delete(key: string) {
|
||||
this._cache.delete(key);
|
||||
this._states.delete(key);
|
||||
|
||||
await fetch(`/api/collection/${this.name}/blob/${key}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
if (this._cache.has(key)) return this._cache.get(key)!;
|
||||
|
||||
let state$ = this._states.get(key);
|
||||
if (!state$) {
|
||||
state$ = new BehaviorSubject<BlobState>(defaultState());
|
||||
|
||||
this._states.set(key, state$);
|
||||
}
|
||||
|
||||
let blob: Blob | null = null;
|
||||
|
||||
nextState(state$, { downloading: true });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/collection/${this.name}/blob/${key}`);
|
||||
|
||||
if (!resp.ok) throw new Error(`Failed to fetch blob ${key}`);
|
||||
|
||||
blob = await resp.blob();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
nextState(state$, { errorMessage });
|
||||
} finally {
|
||||
nextState(state$, { downloading: false });
|
||||
|
||||
if (blob) {
|
||||
this._cache.set(key, blob);
|
||||
}
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
async list() {
|
||||
return Array.from(this._cache.keys());
|
||||
}
|
||||
|
||||
async set(key: string, value: Blob) {
|
||||
let state$ = this._states.get(key);
|
||||
if (!state$) {
|
||||
state$ = new BehaviorSubject<BlobState>(defaultState());
|
||||
|
||||
this._states.set(key, state$);
|
||||
}
|
||||
|
||||
this._cache.set(key, value);
|
||||
|
||||
nextState(state$, { uploading: true });
|
||||
|
||||
try {
|
||||
await fetch(`/api/collection/${this.name}/blob/${key}`, {
|
||||
method: 'PUT',
|
||||
body: value,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
nextState(state$, { errorMessage });
|
||||
} finally {
|
||||
nextState(state$, { uploading: false });
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
blobState$(key: string) {
|
||||
let state$ = this._states.get(key);
|
||||
|
||||
if (!state$) {
|
||||
state$ = new BehaviorSubject<BlobState>(defaultState());
|
||||
|
||||
this._states.set(key, state$);
|
||||
|
||||
nextState(state$, { errorMessage: 'Blob not found' });
|
||||
}
|
||||
|
||||
return state$.pipe(
|
||||
throttleTime(1000, undefined, { leading: true, trailing: true }),
|
||||
share({
|
||||
connector: () => new ReplaySubject(1),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultState(): BlobState {
|
||||
return {
|
||||
uploading: false,
|
||||
downloading: false,
|
||||
overSize: false,
|
||||
needDownload: false,
|
||||
needUpload: false,
|
||||
};
|
||||
}
|
||||
|
||||
function nextState(
|
||||
state$: BehaviorSubject<BlobState>,
|
||||
state?: Partial<BlobState>
|
||||
) {
|
||||
state$.next({ ...state$.value, ...state });
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AwarenessSource } from '@blocksuite/sync';
|
||||
import type { Awareness } from 'y-protocols/awareness';
|
||||
import {
|
||||
applyAwarenessUpdate,
|
||||
encodeAwarenessUpdate,
|
||||
} from 'y-protocols/awareness';
|
||||
|
||||
import type { WebSocketMessage } from './types';
|
||||
|
||||
type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>;
|
||||
|
||||
export class WebSocketAwarenessSource implements AwarenessSource {
|
||||
private readonly _onAwareness = (
|
||||
changes: AwarenessChanges,
|
||||
origin: unknown
|
||||
) => {
|
||||
if (origin === 'remote') return;
|
||||
|
||||
const changedClients = Object.values(changes).reduce((res, cur) =>
|
||||
res.concat(cur)
|
||||
);
|
||||
|
||||
if (!this.awareness) {
|
||||
throw new Error('awareness is not found');
|
||||
}
|
||||
const update = encodeAwarenessUpdate(this.awareness, changedClients);
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
channel: 'awareness',
|
||||
payload: {
|
||||
type: 'update',
|
||||
update: Array.from(update),
|
||||
},
|
||||
} satisfies WebSocketMessage)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _onWebSocket = (event: MessageEvent<string>) => {
|
||||
const data = JSON.parse(event.data) as WebSocketMessage;
|
||||
|
||||
if (data.channel !== 'awareness') return;
|
||||
const { type } = data.payload;
|
||||
|
||||
if (type === 'update') {
|
||||
const update = data.payload.update;
|
||||
if (!this.awareness) {
|
||||
throw new Error('awareness is not found');
|
||||
}
|
||||
applyAwarenessUpdate(this.awareness, new Uint8Array(update), 'remote');
|
||||
}
|
||||
|
||||
if (type === 'connect') {
|
||||
if (!this.awareness) {
|
||||
throw new Error('awareness is not found');
|
||||
}
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
channel: 'awareness',
|
||||
payload: {
|
||||
type: 'update',
|
||||
update: Array.from(
|
||||
encodeAwarenessUpdate(this.awareness, [this.awareness.clientID])
|
||||
),
|
||||
},
|
||||
} satisfies WebSocketMessage)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
awareness: Awareness | null = null;
|
||||
|
||||
constructor(readonly ws: WebSocket) {}
|
||||
|
||||
connect(awareness: Awareness): void {
|
||||
this.awareness = awareness;
|
||||
awareness.on('update', this._onAwareness);
|
||||
|
||||
this.ws.addEventListener('message', this._onWebSocket);
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
channel: 'awareness',
|
||||
payload: {
|
||||
type: 'connect',
|
||||
},
|
||||
} satisfies WebSocketMessage)
|
||||
);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.awareness?.off('update', this._onAwareness);
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
104
blocksuite/playground/apps/_common/sync/websocket/doc.ts
Normal file
104
blocksuite/playground/apps/_common/sync/websocket/doc.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { DocSource } from '@blocksuite/sync';
|
||||
import { diffUpdate, encodeStateVectorFromUpdate, mergeUpdates } from 'yjs';
|
||||
|
||||
import type { WebSocketMessage } from './types';
|
||||
|
||||
export class WebSocketDocSource implements DocSource {
|
||||
private readonly _onMessage = (event: MessageEvent<string>) => {
|
||||
const data = JSON.parse(event.data) as WebSocketMessage;
|
||||
|
||||
if (data.channel !== 'doc') return;
|
||||
|
||||
if (data.payload.type === 'init') {
|
||||
for (const [docId, data] of this.docMap) {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
channel: 'doc',
|
||||
payload: {
|
||||
type: 'update',
|
||||
docId,
|
||||
updates: Array.from(data),
|
||||
},
|
||||
} satisfies WebSocketMessage)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { docId, updates } = data.payload;
|
||||
const update = this.docMap.get(docId);
|
||||
if (update) {
|
||||
this.docMap.set(docId, mergeUpdates([update, new Uint8Array(updates)]));
|
||||
} else {
|
||||
this.docMap.set(docId, new Uint8Array(updates));
|
||||
}
|
||||
};
|
||||
|
||||
docMap = new Map<string, Uint8Array>();
|
||||
|
||||
name = 'websocket';
|
||||
|
||||
constructor(readonly ws: WebSocket) {
|
||||
this.ws.addEventListener('message', this._onMessage);
|
||||
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
channel: 'doc',
|
||||
payload: {
|
||||
type: 'init',
|
||||
},
|
||||
} satisfies WebSocketMessage)
|
||||
);
|
||||
}
|
||||
|
||||
pull(docId: string, state: Uint8Array) {
|
||||
const update = this.docMap.get(docId);
|
||||
if (!update) return null;
|
||||
|
||||
const diff = state.length ? diffUpdate(update, state) : update;
|
||||
return { data: diff, state: encodeStateVectorFromUpdate(update) };
|
||||
}
|
||||
|
||||
push(docId: string, data: Uint8Array) {
|
||||
const update = this.docMap.get(docId);
|
||||
if (update) {
|
||||
this.docMap.set(docId, mergeUpdates([update, data]));
|
||||
} else {
|
||||
this.docMap.set(docId, data);
|
||||
}
|
||||
|
||||
const latest = this.docMap.get(docId);
|
||||
if (!latest) {
|
||||
throw new Error('latest is not found');
|
||||
}
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
channel: 'doc',
|
||||
payload: {
|
||||
type: 'update',
|
||||
docId,
|
||||
updates: Array.from(latest),
|
||||
},
|
||||
} satisfies WebSocketMessage)
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(cb: (docId: string, data: Uint8Array) => void) {
|
||||
const abortController = new AbortController();
|
||||
this.ws.addEventListener(
|
||||
'message',
|
||||
(event: MessageEvent<string>) => {
|
||||
const data = JSON.parse(event.data) as WebSocketMessage;
|
||||
|
||||
if (data.channel !== 'doc' || data.payload.type !== 'update') return;
|
||||
|
||||
const { docId, updates } = data.payload;
|
||||
cb(docId, new Uint8Array(updates));
|
||||
},
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}
|
||||
}
|
||||
19
blocksuite/playground/apps/_common/sync/websocket/types.ts
Normal file
19
blocksuite/playground/apps/_common/sync/websocket/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type AwarenessMessage = {
|
||||
channel: 'awareness';
|
||||
payload: { type: 'connect' } | { type: 'update'; update: number[] };
|
||||
};
|
||||
|
||||
export type DocMessage = {
|
||||
channel: 'doc';
|
||||
payload:
|
||||
| {
|
||||
type: 'init';
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
docId: string;
|
||||
updates: number[];
|
||||
};
|
||||
};
|
||||
|
||||
export type WebSocketMessage = AwarenessMessage | DocMessage;
|
||||
@@ -0,0 +1,8 @@
|
||||
const BASE_URL = new URL(import.meta.env.PLAYGROUND_SERVER);
|
||||
export async function generateRoomId(): Promise<string> {
|
||||
return fetch(new URL('/room/', BASE_URL), {
|
||||
method: 'post',
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(({ id }) => id);
|
||||
}
|
||||
1082
blocksuite/playground/apps/_common/templates/stickers.ts
Normal file
1082
blocksuite/playground/apps/_common/templates/stickers.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user