snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
2
blocksuite/playground/.env
Normal file
2
blocksuite/playground/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
PLAYGROUND_SERVER=https://blocksuite-playground.toeverything.workers.dev
|
||||
PLAYGROUND_WS=wss://blocksuite-playground.toeverything.workers.dev
|
||||
27
blocksuite/playground/.gitignore
vendored
Normal file
27
blocksuite/playground/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
apps/starter/data/snapshots/*.zip
|
||||
|
||||
3
blocksuite/playground/apps/README.md
Normal file
3
blocksuite/playground/apps/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# BlockSuite Playground Apps
|
||||
|
||||
This directory contains application entries used for the [BlockSuite playground](https://try-blocksuite.vercel.app) online site. They serve as comprehensive examples utilizing the full capabilities of BlockSuite, and also act as the entry point for executing E2E test cases.
|
||||
@@ -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
123
blocksuite/playground/apps/comment/comment-input.ts
Normal file
123
blocksuite/playground/apps/comment/comment-input.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { RichText } from '@blocksuite/affine/rich-text';
|
||||
import { ShadowlessElement, TextSelection } from '@blocksuite/affine/std';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import type { Comment, CommentManager } from './comment-manager.js';
|
||||
|
||||
export class CommentInput extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.comment-input-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.comment-quote {
|
||||
font-size: 10px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--affine-text-secondary-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-editor {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
min-height: 24px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comment-control {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _cancel = () => {
|
||||
this.remove();
|
||||
};
|
||||
|
||||
private readonly _submit = (textSelection: TextSelection) => {
|
||||
const deltas = this._editor.inlineEditor?.yTextDeltas;
|
||||
if (!deltas) {
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const yText = new Y.Text();
|
||||
yText.applyDelta(deltas);
|
||||
const comment = this.manager.addComment(textSelection, {
|
||||
author: 'Anonymous',
|
||||
text: yText,
|
||||
});
|
||||
|
||||
this.onSubmit?.(comment);
|
||||
|
||||
this.remove();
|
||||
};
|
||||
|
||||
get host() {
|
||||
return this.manager.host;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const textSelection = this.host.selection.find(TextSelection);
|
||||
if (!textSelection) {
|
||||
this.remove();
|
||||
return nothing;
|
||||
}
|
||||
const parseResult = this.manager.parseTextSelection(textSelection);
|
||||
if (!parseResult) {
|
||||
this.remove();
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { quote } = parseResult;
|
||||
|
||||
const tmpYDoc = new Y.Doc();
|
||||
const tmpYText = tmpYDoc.getText('comment');
|
||||
|
||||
return html`<div class="comment-input-container">
|
||||
<div class="comment-state">
|
||||
<div class="comment-quote">${quote}</div>
|
||||
<div class="comment-author">Anonymous</div>
|
||||
</div>
|
||||
<rich-text
|
||||
@blur=${() => this._submit(textSelection)}
|
||||
.yText=${tmpYText}
|
||||
class="comment-editor"
|
||||
></rich-text>
|
||||
<div class="comment-control">
|
||||
<button
|
||||
@click=${() => this._submit(textSelection)}
|
||||
class="comment-submit"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button @click=${this._cancel} class="comment-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _editor!: RichText;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor manager!: CommentManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSubmit: undefined | ((comment: Comment) => void) = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'comment-input': CommentInput;
|
||||
}
|
||||
}
|
||||
160
blocksuite/playground/apps/comment/comment-manager.ts
Normal file
160
blocksuite/playground/apps/comment/comment-manager.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { EditorHost, TextSelection } from '@blocksuite/affine/std';
|
||||
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export interface CommentMeta {
|
||||
id: string;
|
||||
date: number;
|
||||
}
|
||||
export interface CommentRange {
|
||||
start: {
|
||||
id: string;
|
||||
index: Y.RelativePosition;
|
||||
};
|
||||
end: {
|
||||
id: string;
|
||||
index: Y.RelativePosition;
|
||||
};
|
||||
}
|
||||
export interface CommentContent {
|
||||
quote: string;
|
||||
author: string;
|
||||
text: Y.Text;
|
||||
}
|
||||
|
||||
export type Comment = CommentMeta & CommentRange & CommentContent;
|
||||
|
||||
export class CommentManager {
|
||||
private get _command() {
|
||||
return this.host.command;
|
||||
}
|
||||
|
||||
get commentsMap() {
|
||||
return this.host.store.spaceDoc.getMap<Y.Map<unknown>>('comments');
|
||||
}
|
||||
|
||||
constructor(readonly host: EditorHost) {}
|
||||
|
||||
addComment(
|
||||
selection: TextSelection,
|
||||
payload: Pick<CommentContent, 'author' | 'text'>
|
||||
): Comment {
|
||||
const parseResult = this.parseTextSelection(selection);
|
||||
if (!parseResult) {
|
||||
throw new Error('Invalid selection');
|
||||
}
|
||||
|
||||
const { quote, range } = parseResult;
|
||||
const id = this.host.store.workspace.idGenerator();
|
||||
const comment: Comment = {
|
||||
id,
|
||||
date: Date.now(),
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
quote,
|
||||
...payload,
|
||||
};
|
||||
this.commentsMap.set(id, new Y.Map<unknown>(Object.entries(comment)));
|
||||
return comment;
|
||||
}
|
||||
|
||||
getComments(): Comment[] {
|
||||
const comments: Comment[] = [];
|
||||
this.commentsMap.forEach((comment, key) => {
|
||||
const start = comment.get('start') as Comment['start'];
|
||||
const end = comment.get('end') as Comment['end'];
|
||||
|
||||
const startIndex = Y.createAbsolutePositionFromRelativePosition(
|
||||
start.index,
|
||||
this.host.store.spaceDoc
|
||||
);
|
||||
const startBlock = this.host.view.getBlock(start.id);
|
||||
const endIndex = Y.createAbsolutePositionFromRelativePosition(
|
||||
end.index,
|
||||
this.host.store.spaceDoc
|
||||
);
|
||||
const endBlock = this.host.view.getBlock(end.id);
|
||||
|
||||
if (!startIndex || !startBlock || !endIndex || !endBlock) {
|
||||
// remove outdated comment
|
||||
this.commentsMap.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Comment = {
|
||||
id: comment.get('id') as Comment['id'],
|
||||
date: comment.get('date') as Comment['date'],
|
||||
start,
|
||||
end,
|
||||
quote: comment.get('quote') as Comment['quote'],
|
||||
author: comment.get('author') as Comment['author'],
|
||||
text: comment.get('text') as Comment['text'],
|
||||
};
|
||||
comments.push(result);
|
||||
});
|
||||
return comments;
|
||||
}
|
||||
|
||||
parseTextSelection(selection: TextSelection): {
|
||||
quote: CommentContent['quote'];
|
||||
range: CommentRange;
|
||||
} | null {
|
||||
const [_, ctx] = this._command
|
||||
.chain()
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
currentTextSelection: selection,
|
||||
types: ['text'],
|
||||
})
|
||||
.run();
|
||||
const blocks = ctx.selectedBlocks;
|
||||
if (!blocks || blocks.length === 0) return null;
|
||||
|
||||
const { from, to } = selection;
|
||||
const fromBlock = blocks[0];
|
||||
const fromBlockText = fromBlock.model.text;
|
||||
const fromBlockId = fromBlock.model.id;
|
||||
const toBlock = blocks[blocks.length - 1];
|
||||
const toBlockText = toBlock.model.text;
|
||||
const toBlockId = toBlock.model.id;
|
||||
if (!fromBlockText || !toBlockText) return null;
|
||||
|
||||
const startIndex = Y.createRelativePositionFromTypeIndex(
|
||||
fromBlockText.yText,
|
||||
from.index
|
||||
);
|
||||
const endIndex = Y.createRelativePositionFromTypeIndex(
|
||||
toBlockText.yText,
|
||||
to ? to.index + to.length : from.index + from.length
|
||||
);
|
||||
const quote = blocks.reduce((acc, block, index) => {
|
||||
const text = block.model.text;
|
||||
if (!text) return acc;
|
||||
|
||||
if (index === 0) {
|
||||
return (
|
||||
acc +
|
||||
text.yText.toString().slice(from.index, from.index + from.length)
|
||||
);
|
||||
}
|
||||
if (index === blocks.length - 1 && to) {
|
||||
return acc + ' ' + text.yText.toString().slice(0, to.index + to.length);
|
||||
}
|
||||
|
||||
return acc + ' ' + text.yText.toString();
|
||||
}, '');
|
||||
|
||||
return {
|
||||
quote,
|
||||
range: {
|
||||
start: {
|
||||
id: fromBlockId,
|
||||
index: startIndex,
|
||||
},
|
||||
end: {
|
||||
id: toBlockId,
|
||||
index: endIndex,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
116
blocksuite/playground/apps/comment/comment-panel.ts
Normal file
116
blocksuite/playground/apps/comment/comment-panel.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { ShadowlessElement, TextSelection } from '@blocksuite/affine/std';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
import { CommentInput } from './comment-input.js';
|
||||
import { CommentManager } from './comment-manager.js';
|
||||
|
||||
export class CommentPanel extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
comment-panel {
|
||||
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;
|
||||
}
|
||||
|
||||
.comment-panel-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.comment-panel-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-panel-comments {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.comment-panel-comment {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comment-panel-comment-quote {
|
||||
font-size: 10px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--affine-text-secondary-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-panel-comment-author {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-panel-comment-text {
|
||||
margin-top: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
commentManager: CommentManager | null = null;
|
||||
|
||||
private _addComment() {
|
||||
const textSelection = this.editor.host?.selection.find(TextSelection);
|
||||
if (!textSelection) return;
|
||||
|
||||
const commentInput = new CommentInput();
|
||||
if (!this.commentManager) return;
|
||||
|
||||
commentInput.manager = this.commentManager;
|
||||
commentInput.onSubmit = () => {
|
||||
this.requestUpdate();
|
||||
};
|
||||
this._container.append(commentInput);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.editor.host) return;
|
||||
this.commentManager = new CommentManager(this.editor.host);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.commentManager) return;
|
||||
const comments = this.commentManager.getComments();
|
||||
|
||||
return html`<div class="comment-panel-container">
|
||||
<div class="comment-panel-head">
|
||||
<button @click=${this._addComment}>Add Comment</button>
|
||||
</div>
|
||||
<div class="comment-panel-comments">
|
||||
${comments.map(comment => {
|
||||
return html`<div class="comment-panel-comment">
|
||||
<div class="comment-panel-comment-quote">${comment.quote}</div>
|
||||
<div class="comment-panel-comment-author">${comment.author}</div>
|
||||
<div class="comment-panel-comment-text">
|
||||
<rich-text .yText=${comment.text} .readonly=${true}></rich-text>
|
||||
</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@query('.comment-panel-container')
|
||||
private accessor _container!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: TestAffineEditorContainer;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'comment-panel': CommentPanel;
|
||||
}
|
||||
}
|
||||
7
blocksuite/playground/apps/comment/effects.ts
Normal file
7
blocksuite/playground/apps/comment/effects.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CommentInput } from './comment-input.js';
|
||||
import { CommentPanel } from './comment-panel.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('comment-input', CommentInput);
|
||||
customElements.define('comment-panel', CommentPanel);
|
||||
}
|
||||
2
blocksuite/playground/apps/comment/index.ts
Normal file
2
blocksuite/playground/apps/comment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './comment-manager.js';
|
||||
export * from './comment-panel.js';
|
||||
28
blocksuite/playground/apps/env.d.ts
vendored
Normal file
28
blocksuite/playground/apps/env.d.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { EditorHost } from '@blocksuite/affine/block-std';
|
||||
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import type { BlockSchema, Blocks, Workspace, Transformer } from '@blocksuite/affine/store';
|
||||
import type { z } from 'zod';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
declare global {
|
||||
type HTMLTemplate = [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
...(HTMLTemplate | string)[],
|
||||
];
|
||||
|
||||
interface Window {
|
||||
editor: TestAffineEditorContainer;
|
||||
doc: Blocks;
|
||||
collection: Workspace;
|
||||
blockSchemas: z.infer<typeof BlockSchema>[];
|
||||
job: Transformer;
|
||||
Y: typeof Y;
|
||||
std: typeof std;
|
||||
host: EditorHost;
|
||||
testWorker: Worker;
|
||||
|
||||
wsProvider: ReturnType<typeof setupBroadcastProvider>;
|
||||
bcProvider: ReturnType<typeof setupBroadcastProvider>;
|
||||
}
|
||||
}
|
||||
24
blocksuite/playground/apps/starter/data/affine-snapshot.ts
Normal file
24
blocksuite/playground/apps/starter/data/affine-snapshot.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AffineSchemas } from '@blocksuite/affine/schemas';
|
||||
import { Schema, Text, type Workspace } from '@blocksuite/affine/store';
|
||||
import { ZipTransformer } from '@blocksuite/affine/widgets/linked-doc';
|
||||
export async function affineSnapshot(collection: Workspace, id: string) {
|
||||
const doc = collection.createDoc(id);
|
||||
doc.load();
|
||||
const store = doc.getStore();
|
||||
// Add root block and surface block at root level
|
||||
const rootId = store.addBlock('affine:page', {
|
||||
title: new Text('Affine Snapshot Test'),
|
||||
});
|
||||
store.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
const path = '/apps/starter/data/snapshots/affine-default.zip';
|
||||
const response = await fetch(path);
|
||||
const file = await response.blob();
|
||||
const schema = new Schema();
|
||||
schema.register(AffineSchemas);
|
||||
await ZipTransformer.importDocs(collection, schema, file);
|
||||
}
|
||||
|
||||
affineSnapshot.id = 'affine-snapshot';
|
||||
affineSnapshot.displayName = 'Affine Snapshot Test';
|
||||
affineSnapshot.description = 'Affine Snapshot Test';
|
||||
136
blocksuite/playground/apps/starter/data/database.ts
Normal file
136
blocksuite/playground/apps/starter/data/database.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
DatabaseBlockDataSource,
|
||||
databaseBlockProperties,
|
||||
} from '@blocksuite/affine/blocks/database';
|
||||
import {
|
||||
type DatabaseBlockModel,
|
||||
type ListType,
|
||||
type ParagraphType,
|
||||
} from '@blocksuite/affine/model';
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
import { groupTraitKey } from '@blocksuite/data-view';
|
||||
import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
import { viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
export const database: InitFn = (collection: Workspace, id: string) => {
|
||||
const doc = collection.createDoc(id);
|
||||
const store = doc.getStore();
|
||||
|
||||
doc.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = store.addBlock('affine:page', {
|
||||
title: new Text('BlockSuite Playground'),
|
||||
});
|
||||
store.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = store.addBlock('affine:note', {}, rootId);
|
||||
const pId = store.addBlock('affine:paragraph', {}, noteId);
|
||||
const model = store.getModelById(pId);
|
||||
if (!model) {
|
||||
throw new Error('model is not found');
|
||||
}
|
||||
const addDatabase = (title: string, group = true) => {
|
||||
const databaseId = store.addBlock(
|
||||
'affine:database',
|
||||
{
|
||||
columns: [],
|
||||
cells: {},
|
||||
},
|
||||
noteId
|
||||
);
|
||||
const database = store.getModelById(databaseId) as DatabaseBlockModel;
|
||||
const datasource = new DatabaseBlockDataSource(database);
|
||||
datasource.viewManager.viewAdd('table');
|
||||
database.props.title = new Text(title);
|
||||
const richTextId = datasource.propertyAdd('end', {
|
||||
type: databaseBlockProperties.richTextColumnConfig.type,
|
||||
name: 'Rich Text',
|
||||
});
|
||||
Object.values([
|
||||
propertyPresets.multiSelectPropertyConfig,
|
||||
propertyPresets.datePropertyConfig,
|
||||
propertyPresets.numberPropertyConfig,
|
||||
databaseBlockProperties.linkColumnConfig,
|
||||
propertyPresets.checkboxPropertyConfig,
|
||||
propertyPresets.progressPropertyConfig,
|
||||
]).forEach(column => {
|
||||
datasource.propertyAdd('end', {
|
||||
type: column.type,
|
||||
name: column.config.name,
|
||||
});
|
||||
});
|
||||
if (group) {
|
||||
const groupTrait =
|
||||
datasource.viewManager.currentView$.value?.traitGet(groupTraitKey);
|
||||
groupTrait?.changeGroup(database.props.columns[1].id);
|
||||
}
|
||||
const paragraphTypes: ParagraphType[] = [
|
||||
'text',
|
||||
'quote',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
];
|
||||
paragraphTypes.forEach(type => {
|
||||
const id = store.addBlock(
|
||||
'affine:paragraph',
|
||||
{ type: type, text: new Text(`Paragraph type ${type}`) },
|
||||
databaseId
|
||||
);
|
||||
if (richTextId) {
|
||||
datasource.cellValueChange(
|
||||
id,
|
||||
richTextId,
|
||||
new Text(`Paragraph type ${type}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
const listTypes: ListType[] = ['numbered', 'bulleted', 'todo', 'toggle'];
|
||||
|
||||
listTypes.forEach(type => {
|
||||
const id = store.addBlock(
|
||||
'affine:list',
|
||||
{ type: type, text: new Text(`List type ${type}`) },
|
||||
databaseId
|
||||
);
|
||||
if (richTextId) {
|
||||
datasource.cellValueChange(
|
||||
id,
|
||||
richTextId,
|
||||
new Text(`List type ${type}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
// Add a paragraph after database
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
datasource.viewManager.viewAdd(viewPresets.kanbanViewMeta.type);
|
||||
|
||||
store.resetHistory();
|
||||
};
|
||||
// Add database block inside note block
|
||||
addDatabase('Database 1', false);
|
||||
addDatabase('Database 2');
|
||||
addDatabase('Database 3');
|
||||
addDatabase('Database 4');
|
||||
addDatabase('Database 5');
|
||||
addDatabase('Database 6');
|
||||
addDatabase('Database 7');
|
||||
addDatabase('Database 8');
|
||||
addDatabase('Database 9');
|
||||
addDatabase('Database 10');
|
||||
});
|
||||
};
|
||||
|
||||
database.id = 'database';
|
||||
database.displayName = 'Database Example';
|
||||
database.description = 'Database block basic example';
|
||||
55
blocksuite/playground/apps/starter/data/embed.ts
Normal file
55
blocksuite/playground/apps/starter/data/embed.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
export const embed: InitFn = (collection: Workspace, id: string) => {
|
||||
const doc = collection.getDoc(id) ?? collection.createDoc(id);
|
||||
const store = doc.getStore();
|
||||
doc.clear();
|
||||
|
||||
doc.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = store.addBlock('affine:page', {
|
||||
title: new Text(),
|
||||
});
|
||||
|
||||
const surfaceId = store.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = store.addBlock('affine:note', {}, rootId);
|
||||
// Add paragraph block inside note block
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
|
||||
store.addBlock(
|
||||
'affine:embed-github',
|
||||
{
|
||||
url: 'https://github.com/toeverything/AFFiNE/pull/5453',
|
||||
},
|
||||
noteId
|
||||
);
|
||||
store.addBlock(
|
||||
'affine:embed-github',
|
||||
{
|
||||
url: 'https://www.github.com/toeverything/blocksuite/pull/5927',
|
||||
style: 'vertical',
|
||||
xywh: '[0, 400, 364, 390]',
|
||||
},
|
||||
surfaceId
|
||||
);
|
||||
store.addBlock(
|
||||
'affine:embed-github',
|
||||
{
|
||||
url: 'https://github.com/Milkdown/milkdown/pull/1215',
|
||||
xywh: '[500, 400, 752, 116]',
|
||||
},
|
||||
surfaceId
|
||||
);
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
});
|
||||
|
||||
store.resetHistory();
|
||||
};
|
||||
|
||||
embed.id = 'embed';
|
||||
embed.displayName = 'Example for embed blocks';
|
||||
embed.description = 'Example for embed blocks';
|
||||
29
blocksuite/playground/apps/starter/data/empty.ts
Normal file
29
blocksuite/playground/apps/starter/data/empty.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
export const empty: InitFn = (collection: Workspace, id: string) => {
|
||||
const doc = collection.getDoc(id) ?? collection.createDoc(id);
|
||||
const store = doc.getStore();
|
||||
doc.clear();
|
||||
|
||||
doc.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = store.addBlock('affine:page', {
|
||||
title: new Text(),
|
||||
});
|
||||
|
||||
store.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = store.addBlock('affine:note', {}, rootId);
|
||||
// Add paragraph block inside note block
|
||||
store.addBlock('affine:paragraph', {}, noteId);
|
||||
});
|
||||
|
||||
store.resetHistory();
|
||||
};
|
||||
|
||||
empty.id = 'empty';
|
||||
empty.displayName = 'Empty Editor';
|
||||
empty.description = 'Start from empty editor';
|
||||
99
blocksuite/playground/apps/starter/data/heavy-whiteboard.ts
Normal file
99
blocksuite/playground/apps/starter/data/heavy-whiteboard.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { SerializedXYWH } from '@blocksuite/affine/global/gfx';
|
||||
import {
|
||||
Boxed,
|
||||
nanoid,
|
||||
native2Y,
|
||||
Text,
|
||||
type Workspace,
|
||||
} from '@blocksuite/affine/store';
|
||||
import { DEFAULT_ROUGHNESS } from '@blocksuite/affine-model';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
const SHAPE_TYPES = ['rect', 'triangle', 'ellipse', 'diamond'];
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
function createShapes(count: number): Record<string, unknown> {
|
||||
const surfaceBlocks: Record<string, unknown> = {};
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = Math.random() * count * 2;
|
||||
const y = Math.random() * count * 2;
|
||||
const id = nanoid();
|
||||
surfaceBlocks[id] = native2Y(
|
||||
{
|
||||
id,
|
||||
index: 'a0',
|
||||
type: 'shape',
|
||||
xywh: `[${x},${y},100,100]`,
|
||||
seed: Math.floor(Math.random() * 2 ** 31),
|
||||
shapeType: SHAPE_TYPES[Math.floor(Math.random() * 40) % 4],
|
||||
radius: 0,
|
||||
filled: false,
|
||||
fillColor: '--affine-palette-shape-yellow',
|
||||
strokeWidth: 4,
|
||||
strokeColor: '--affine-palette-line-yellow',
|
||||
strokeStyle: 'solid',
|
||||
roughness: DEFAULT_ROUGHNESS,
|
||||
},
|
||||
{ deep: false }
|
||||
);
|
||||
}
|
||||
return surfaceBlocks;
|
||||
}
|
||||
|
||||
const SHAPES_COUNT = 100;
|
||||
const RANGE = 2000;
|
||||
|
||||
export const heavyWhiteboard: InitFn = (collection: Workspace, id: string) => {
|
||||
const count = Number(params.get('count')) || SHAPES_COUNT;
|
||||
const enableShapes = !!params.get('shapes');
|
||||
|
||||
const doc = collection.createDoc(id);
|
||||
const store = doc.getStore();
|
||||
doc.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = store.addBlock('affine:page', {
|
||||
title: new Text(),
|
||||
});
|
||||
|
||||
const surfaceBlocks = enableShapes ? createShapes(count) : {};
|
||||
|
||||
store.addBlock(
|
||||
'affine:surface',
|
||||
{
|
||||
elements: new Boxed(native2Y(surfaceBlocks, { deep: false })) as Boxed<
|
||||
Y.Map<Y.Map<unknown>>
|
||||
>,
|
||||
},
|
||||
rootId
|
||||
);
|
||||
|
||||
let i = 0;
|
||||
// Add note block inside root block
|
||||
for (i = 0; i < count; i++) {
|
||||
const x = Math.random() * RANGE - RANGE / 2;
|
||||
const y = Math.random() * RANGE - RANGE / 2;
|
||||
const noteId = store.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
xywh: `[${x}, ${y}, 100, 50]` as SerializedXYWH,
|
||||
},
|
||||
rootId
|
||||
);
|
||||
// Add paragraph block inside note block
|
||||
store.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new Text('Note #' + i),
|
||||
},
|
||||
noteId
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
heavyWhiteboard.id = 'heavy-whiteboard';
|
||||
heavyWhiteboard.displayName = 'Heavy Whiteboard';
|
||||
heavyWhiteboard.description = 'Heavy Whiteboard on 200 elements by default';
|
||||
36
blocksuite/playground/apps/starter/data/heavy.ts
Normal file
36
blocksuite/playground/apps/starter/data/heavy.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
export const heavy: InitFn = (collection: Workspace, docId: string) => {
|
||||
const count = Number(params.get('count')) || 1000;
|
||||
|
||||
const doc = collection.createDoc(docId);
|
||||
const store = doc.getStore();
|
||||
doc.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = store.addBlock('affine:page', {
|
||||
title: new Text(),
|
||||
});
|
||||
store.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = store.addBlock('affine:note', {}, rootId);
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Add paragraph block inside note block
|
||||
store.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new Text('Hello, world! ' + i),
|
||||
},
|
||||
noteId
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
heavy.id = 'heavy';
|
||||
heavy.displayName = 'Heavy Example';
|
||||
heavy.description = 'Heavy example on thousands of paragraph blocks';
|
||||
19
blocksuite/playground/apps/starter/data/index.ts
Normal file
19
blocksuite/playground/apps/starter/data/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Manually create initial page structure.
|
||||
* In collaboration mode or on page refresh with local persistence,
|
||||
* the page structure will be automatically loaded from provider.
|
||||
* In these cases, these functions should not be called.
|
||||
*/
|
||||
export * from './affine-snapshot.js';
|
||||
export * from './database.js';
|
||||
export * from './embed.js';
|
||||
export * from './empty.js';
|
||||
export * from './heavy.js';
|
||||
export * from './heavy-whiteboard.js';
|
||||
export * from './linked.js';
|
||||
export * from './multiple-editor.js';
|
||||
export * from './pending-structs.js';
|
||||
export * from './preset.js';
|
||||
export * from './synced.js';
|
||||
export type { InitFn } from './utils.js';
|
||||
export * from './version-mismatch.js';
|
||||
82
blocksuite/playground/apps/starter/data/linked.ts
Normal file
82
blocksuite/playground/apps/starter/data/linked.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
export const linked: InitFn = (collection: Workspace, id: string) => {
|
||||
const docA =
|
||||
collection.getDoc(id)?.getStore({ id }) ??
|
||||
collection.createDoc(id).getStore({ id });
|
||||
|
||||
const docBId = 'doc:linked-page';
|
||||
const docB = collection.createDoc(docBId).getStore();
|
||||
|
||||
const docCId = 'doc:linked-edgeless';
|
||||
const docC = collection.createDoc(docCId).getStore();
|
||||
|
||||
docA.doc.clear();
|
||||
docB.doc.clear();
|
||||
docC.doc.clear();
|
||||
|
||||
docB.load(() => {
|
||||
const rootId = docB.addBlock('affine:page', {
|
||||
title: new Text(''),
|
||||
});
|
||||
|
||||
docB.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = docB.addBlock('affine:note', {}, rootId);
|
||||
// Add paragraph block inside note block
|
||||
docB.addBlock('affine:paragraph', {}, noteId);
|
||||
});
|
||||
|
||||
docC.load(() => {
|
||||
const rootId = docC.addBlock('affine:page', {
|
||||
title: new Text(''),
|
||||
});
|
||||
|
||||
docC.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = docC.addBlock('affine:note', {}, rootId);
|
||||
// Add paragraph block inside note block
|
||||
docC.addBlock('affine:paragraph', {}, noteId);
|
||||
});
|
||||
|
||||
docA.load();
|
||||
// Add root block and surface block at root level
|
||||
const rootId = docA.addBlock('affine:page', {
|
||||
title: new Text('Doc A'),
|
||||
});
|
||||
|
||||
docA.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = docA.addBlock('affine:note', {}, rootId);
|
||||
// Add paragraph block inside note block
|
||||
docA.addBlock('affine:paragraph', {}, noteId);
|
||||
|
||||
docA.addBlock('affine:embed-linked-doc', { pageId: docBId }, noteId);
|
||||
|
||||
docA.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
{ pageId: 'doc:deleted-example' },
|
||||
noteId
|
||||
);
|
||||
|
||||
docA.addBlock('affine:embed-linked-doc', { pageId: docCId }, noteId);
|
||||
|
||||
docA.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
{ pageId: 'doc:deleted-example-edgeless' },
|
||||
noteId
|
||||
);
|
||||
|
||||
docA.resetHistory();
|
||||
docB.resetHistory();
|
||||
docC.resetHistory();
|
||||
};
|
||||
|
||||
linked.id = 'linked';
|
||||
linked.displayName = 'Linked Doc Editor';
|
||||
linked.description = 'A demo with linked docs';
|
||||
72
blocksuite/playground/apps/starter/data/multiple-editor.ts
Normal file
72
blocksuite/playground/apps/starter/data/multiple-editor.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
|
||||
import { createTestEditor } from '../utils/extensions.js';
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
export const multiEditor: InitFn = (collection: Workspace, id: string) => {
|
||||
const doc = collection.createDoc(id).getStore({ id });
|
||||
doc.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = doc.addBlock('affine:page', {
|
||||
title: new Text(),
|
||||
});
|
||||
|
||||
doc.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = doc.addBlock('affine:note', {}, rootId);
|
||||
// Add paragraph block inside note block
|
||||
doc.addBlock('affine:paragraph', {}, noteId);
|
||||
});
|
||||
|
||||
doc.resetHistory();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
if (app) {
|
||||
const editor = createTestEditor(doc, collection);
|
||||
editor.style.borderRight = '1px solid var(--affine-border-color)';
|
||||
|
||||
app.append(editor);
|
||||
app.style.display = 'flex';
|
||||
}
|
||||
};
|
||||
|
||||
multiEditor.id = 'multiple-editor';
|
||||
multiEditor.displayName = 'Multiple Editor Example';
|
||||
multiEditor.description = 'Multiple Editor basic example';
|
||||
|
||||
export const multiEditorVertical: InitFn = (
|
||||
collection: Workspace,
|
||||
docId: string
|
||||
) => {
|
||||
const doc = collection.createDoc(docId).getStore();
|
||||
doc.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = doc.addBlock('affine:page', {
|
||||
title: new Text(),
|
||||
});
|
||||
|
||||
doc.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = doc.addBlock('affine:note', {}, rootId);
|
||||
// Add paragraph block inside note block
|
||||
doc.addBlock('affine:paragraph', {}, noteId);
|
||||
});
|
||||
|
||||
doc.resetHistory();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
if (app) {
|
||||
const editor = createTestEditor(doc, collection);
|
||||
editor.style.borderBottom = '1px solid var(--affine-border-color)';
|
||||
|
||||
app.append(editor);
|
||||
app.style.display = 'flex';
|
||||
app.style.flexDirection = 'column';
|
||||
}
|
||||
};
|
||||
|
||||
multiEditorVertical.id = 'multiple-editor-vertical';
|
||||
multiEditorVertical.displayName = 'Vertical Multiple Editor Example';
|
||||
multiEditorVertical.description = 'Multiple Editor vertical layout example';
|
||||
39
blocksuite/playground/apps/starter/data/pending-structs.ts
Normal file
39
blocksuite/playground/apps/starter/data/pending-structs.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
export const pendingStructs: InitFn = (collection: Workspace, id: string) => {
|
||||
const doc = collection.createDoc(id).getStore({ id });
|
||||
const tempDoc = collection.createDoc('tempDoc').getStore();
|
||||
doc.load();
|
||||
tempDoc.load(() => {
|
||||
const rootId = tempDoc.addBlock('affine:page', {
|
||||
title: new Text('Pending Structs'),
|
||||
});
|
||||
const vec = Y.encodeStateVector(tempDoc.spaceDoc);
|
||||
|
||||
// To avoid pending structs, uncomment the following line
|
||||
// const update = Y.encodeStateAsUpdate(tempDoc.spaceDoc);
|
||||
|
||||
tempDoc.addBlock('affine:surface', {}, rootId);
|
||||
// Add note block inside root block
|
||||
const noteId = tempDoc.addBlock('affine:note', {}, rootId);
|
||||
tempDoc.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new Text('This is a paragraph block'),
|
||||
},
|
||||
noteId
|
||||
);
|
||||
const diff = Y.encodeStateAsUpdate(tempDoc.spaceDoc, vec);
|
||||
// To avoid pending structs, uncomment the following line
|
||||
// Y.applyUpdate(doc.spaceDoc, update);
|
||||
|
||||
Y.applyUpdate(doc.spaceDoc, diff);
|
||||
});
|
||||
};
|
||||
|
||||
pendingStructs.id = 'pending-structs';
|
||||
pendingStructs.displayName = 'Pending Structs';
|
||||
pendingStructs.description = 'Doc with pending structs';
|
||||
48
blocksuite/playground/apps/starter/data/preset.ts
Normal file
48
blocksuite/playground/apps/starter/data/preset.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
|
||||
import { getTestStoreManager } from '@blocksuite/integration-test/store';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
const presetMarkdown = `Click the 🔁 button to switch between editors dynamically - they are fully compatible!`;
|
||||
|
||||
export const preset: InitFn = async (collection: Workspace, id: string) => {
|
||||
let doc = collection.getDoc(id);
|
||||
const hasDoc = !!doc;
|
||||
if (!doc) {
|
||||
doc = collection.createDoc(id);
|
||||
}
|
||||
|
||||
const store = doc.getStore({ id });
|
||||
store.load();
|
||||
|
||||
// Run only once on all clients.
|
||||
let noteId: string;
|
||||
if (!hasDoc) {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = store.addBlock('affine:page', {
|
||||
title: new Text('BlockSuite Playground'),
|
||||
});
|
||||
store.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
noteId = store.addBlock(
|
||||
'affine:note',
|
||||
{ xywh: '[0, 100, 800, 640]' },
|
||||
rootId
|
||||
);
|
||||
// Import preset markdown content inside note block
|
||||
await MarkdownTransformer.importMarkdownToBlock({
|
||||
doc: store,
|
||||
blockId: noteId,
|
||||
markdown: presetMarkdown,
|
||||
extensions: getTestStoreManager().get('store'),
|
||||
});
|
||||
}
|
||||
|
||||
store.resetHistory();
|
||||
};
|
||||
|
||||
preset.id = 'preset';
|
||||
preset.displayName = 'BlockSuite Starter';
|
||||
preset.description = 'Start from friendly introduction';
|
||||
175
blocksuite/playground/apps/starter/data/synced.ts
Normal file
175
blocksuite/playground/apps/starter/data/synced.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Text, type Workspace } from '@blocksuite/affine/store';
|
||||
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
|
||||
import { getTestStoreManager } from '@blocksuite/integration-test/store';
|
||||
|
||||
import type { InitFn } from './utils';
|
||||
|
||||
const syncedDocMarkdown = `We share some of our findings from developing local-first software prototypes at [Ink & Switch](https://www.inkandswitch.com/) over the course of several years. These experiments test the viability of CRDTs in practice, and explore the user interface challenges for this new data model. Lastly, we suggest some next steps for moving towards local-first software: for researchers, for app developers, and a startup opportunity for entrepreneurs.
|
||||
|
||||
This article has also been published [in PDF format](https://www.inkandswitch.com/local-first/static/local-first.pdf) in the proceedings of the [Onward! 2019 conference](https://2019.splashcon.org/track/splash-2019-Onward-Essays). Please cite it as:
|
||||
|
||||
> Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. Local-first software: you own your data, in spite of the cloud. 2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software (Onward!), October 2019, pages 154-178. [doi:10.1145/3359591.3359737](https://doi.org/10.1145/3359591.3359737)
|
||||
|
||||
We welcome your feedback: [@inkandswitch](https://twitter.com/inkandswitch) or hello@inkandswitch.com.`;
|
||||
|
||||
export const synced: InitFn = (collection: Workspace, id: string) => {
|
||||
const docMain =
|
||||
collection.getDoc(id)?.getStore({ id }) ??
|
||||
collection.createDoc(id).getStore({ id });
|
||||
|
||||
const docSyncedPageId = 'doc:synced-page';
|
||||
const docSyncedPage = collection.createDoc(docSyncedPageId).getStore();
|
||||
|
||||
const docSyncedEdgelessId = 'doc:synced-edgeless';
|
||||
const docSyncedEdgeless = collection
|
||||
.createDoc(docSyncedEdgelessId)
|
||||
.getStore();
|
||||
|
||||
docMain.doc.clear();
|
||||
docSyncedPage.doc.clear();
|
||||
docSyncedEdgeless.doc.clear();
|
||||
|
||||
docSyncedPage.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = docSyncedPage.addBlock('affine:page', {
|
||||
title: new Text('Synced - Page View'),
|
||||
});
|
||||
|
||||
docSyncedPage.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = docSyncedPage.addBlock('affine:note', {}, rootId);
|
||||
|
||||
// Add markdown to note block
|
||||
MarkdownTransformer.importMarkdownToBlock({
|
||||
doc: docSyncedPage,
|
||||
blockId: noteId,
|
||||
markdown: syncedDocMarkdown,
|
||||
extensions: getTestStoreManager().get('store'),
|
||||
}).catch(console.error);
|
||||
});
|
||||
|
||||
docSyncedEdgeless.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = docSyncedEdgeless.addBlock('affine:page', {
|
||||
title: new Text('Synced - Edgeless View'),
|
||||
});
|
||||
|
||||
docSyncedEdgeless.addBlock('affine:surface', {}, rootId);
|
||||
|
||||
// Add note block inside root block
|
||||
const noteId = docSyncedEdgeless.addBlock('affine:note', {}, rootId);
|
||||
|
||||
// Add markdown to note block
|
||||
MarkdownTransformer.importMarkdownToBlock({
|
||||
doc: docSyncedEdgeless,
|
||||
blockId: noteId,
|
||||
markdown: syncedDocMarkdown,
|
||||
extensions: getTestStoreManager().get('store'),
|
||||
}).catch(console.error);
|
||||
});
|
||||
|
||||
docMain.load(() => {
|
||||
// Add root block and surface block at root level
|
||||
const rootId = docMain.addBlock('affine:page', {
|
||||
title: new Text('Home doc, having synced blocks'),
|
||||
});
|
||||
|
||||
const surfaceId = docMain.addBlock('affine:surface', {}, rootId);
|
||||
const noteId = docMain.addBlock('affine:note', {}, rootId);
|
||||
|
||||
// Add markdown to note block
|
||||
MarkdownTransformer.importMarkdownToBlock({
|
||||
doc: docMain,
|
||||
blockId: noteId,
|
||||
markdown: syncedDocMarkdown,
|
||||
extensions: getTestStoreManager().get('store'),
|
||||
})
|
||||
.then(() => {
|
||||
// Add synced block - self
|
||||
docMain.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new Text('Cyclic / Matryoshka synced block 👇'),
|
||||
type: 'h4',
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
// Add synced block - self
|
||||
docMain.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
pageId: id,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
// Add synced block - page view
|
||||
docMain.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
pageId: docSyncedPageId,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
// Add synced block - edgeless view
|
||||
docMain.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
pageId: docSyncedEdgelessId,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
// Add synced block - page view
|
||||
docMain.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
pageId: docSyncedPageId,
|
||||
xywh: '[-1000, 0, 752, 455]',
|
||||
},
|
||||
surfaceId
|
||||
);
|
||||
|
||||
// Add synced block - edgeless view
|
||||
docMain.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
pageId: docSyncedEdgelessId,
|
||||
xywh: '[-1000, 500, 752, 455]',
|
||||
},
|
||||
surfaceId
|
||||
);
|
||||
|
||||
// Add synced block - self
|
||||
docMain.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
pageId: id,
|
||||
xywh: '[-1000, 1000, 752, 455]',
|
||||
},
|
||||
surfaceId
|
||||
);
|
||||
|
||||
// Add synced block - self
|
||||
docMain.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
pageId: 'doc:deleted-page',
|
||||
},
|
||||
noteId
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
docSyncedEdgeless.resetHistory();
|
||||
docSyncedPage.resetHistory();
|
||||
docMain.resetHistory();
|
||||
};
|
||||
|
||||
synced.id = 'synced';
|
||||
synced.displayName = 'Synced block demo';
|
||||
synced.description = 'A simple demo for synced block';
|
||||
8
blocksuite/playground/apps/starter/data/utils.ts
Normal file
8
blocksuite/playground/apps/starter/data/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Workspace } from '@blocksuite/affine/store';
|
||||
|
||||
export interface InitFn {
|
||||
(collection: Workspace, docId: string): Promise<void> | void;
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
}
|
||||
36
blocksuite/playground/apps/starter/data/version-mismatch.ts
Normal file
36
blocksuite/playground/apps/starter/data/version-mismatch.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Workspace } from '@blocksuite/affine/store';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import type { InitFn } from './utils.js';
|
||||
|
||||
export const versionMismatch: InitFn = (collection: Workspace, id: string) => {
|
||||
const doc = collection.createDoc(id).getStore({ id });
|
||||
const tempDoc = collection.createDoc('tempDoc').getStore();
|
||||
doc.load();
|
||||
|
||||
tempDoc.load(() => {
|
||||
const rootId = tempDoc.addBlock('affine:page', {});
|
||||
tempDoc.addBlock('affine:surface', {}, rootId);
|
||||
const noteId = tempDoc.addBlock(
|
||||
'affine:note',
|
||||
{ xywh: '[0, 100, 800, 640]' },
|
||||
rootId
|
||||
);
|
||||
const paragraphId = tempDoc.addBlock('affine:paragraph', {}, noteId);
|
||||
const blocks = tempDoc.spaceDoc.get('blocks') as Y.Map<unknown>;
|
||||
const paragraph = blocks.get(paragraphId) as Y.Map<unknown>;
|
||||
paragraph.set('sys:version', (paragraph.get('sys:version') as number) + 1);
|
||||
|
||||
const update = Y.encodeStateAsUpdate(tempDoc.spaceDoc);
|
||||
|
||||
Y.applyUpdate(doc.spaceDoc, update);
|
||||
doc.addBlock('affine:paragraph', {}, noteId);
|
||||
});
|
||||
|
||||
collection.removeDoc('tempDoc');
|
||||
doc.resetHistory();
|
||||
};
|
||||
|
||||
versionMismatch.id = 'version-mismatch';
|
||||
versionMismatch.displayName = 'Version Mismatch';
|
||||
versionMismatch.description = 'Error boundary when version mismatch in data';
|
||||
61
blocksuite/playground/apps/starter/main.ts
Normal file
61
blocksuite/playground/apps/starter/main.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import '../../style.css';
|
||||
|
||||
import * as databaseBlocks from '@blocksuite/affine/blocks/database';
|
||||
import * as noteBlocks from '@blocksuite/affine/blocks/note';
|
||||
import * as globalUtils from '@blocksuite/affine/global/utils';
|
||||
import * as services from '@blocksuite/affine/shared/services';
|
||||
import * as blockStd from '@blocksuite/affine/std';
|
||||
import * as store from '@blocksuite/affine/store';
|
||||
import * as affineModel from '@blocksuite/affine-model';
|
||||
import * as editor from '@blocksuite/integration-test';
|
||||
import { effects as itEffects } from '@blocksuite/integration-test/effects';
|
||||
import { getTestStoreManager } from '@blocksuite/integration-test/store';
|
||||
|
||||
import { setupEdgelessTemplate } from '../_common/setup.js';
|
||||
import { effects as commentEffects } from '../comment/effects.js';
|
||||
import {
|
||||
createStarterDocCollection,
|
||||
initStarterDocCollection,
|
||||
} from './utils/collection.js';
|
||||
import { mountDefaultDocEditor } from './utils/setup-playground';
|
||||
import { prepareTestApp } from './utils/test';
|
||||
|
||||
itEffects();
|
||||
const storeManager = getTestStoreManager();
|
||||
commentEffects();
|
||||
|
||||
async function main() {
|
||||
if (window.collection) return;
|
||||
|
||||
setupEdgelessTemplate();
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const room = params.get('room') ?? Math.random().toString(16).slice(2, 8);
|
||||
const isE2E = room.startsWith('playwright');
|
||||
const collection = createStarterDocCollection(storeManager);
|
||||
|
||||
if (isE2E) {
|
||||
Object.defineProperty(window, '$blocksuite', {
|
||||
value: Object.freeze({
|
||||
store,
|
||||
blocks: {
|
||||
database: databaseBlocks,
|
||||
note: noteBlocks,
|
||||
},
|
||||
global: { utils: globalUtils },
|
||||
services,
|
||||
editor,
|
||||
blockStd: blockStd,
|
||||
affineModel: affineModel,
|
||||
}),
|
||||
});
|
||||
await prepareTestApp(collection);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await initStarterDocCollection(collection);
|
||||
await mountDefaultDocEditor(collection);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
93
blocksuite/playground/apps/starter/utils/app.ts
Normal file
93
blocksuite/playground/apps/starter/utils/app.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Store, Workspace } from '@blocksuite/affine/store';
|
||||
import {
|
||||
defaultImageProxyMiddleware,
|
||||
docLinkBaseURLMiddlewareBuilder,
|
||||
embedSyncedDocMiddleware,
|
||||
titleMiddleware,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { AttachmentViewerPanel } from '../../_common/components/attachment-viewer-panel';
|
||||
import { CustomAdapterPanel } from '../../_common/components/custom-adapter-panel';
|
||||
import { CustomFramePanel } from '../../_common/components/custom-frame-panel';
|
||||
import { CustomOutlinePanel } from '../../_common/components/custom-outline-panel';
|
||||
import { CustomOutlineViewer } from '../../_common/components/custom-outline-viewer';
|
||||
import { DocsPanel } from '../../_common/components/docs-panel';
|
||||
import { LeftSidePanel } from '../../_common/components/left-side-panel';
|
||||
import { StarterDebugMenu } from '../../_common/components/starter-debug-menu';
|
||||
import { CommentPanel } from '../../comment/comment-panel';
|
||||
import { createTestEditor } from './extensions';
|
||||
|
||||
export async function createTestApp(doc: Store, collection: Workspace) {
|
||||
const app = document.querySelector('#app');
|
||||
if (!app) {
|
||||
throw new Error('Cannot find app root element(#app).');
|
||||
}
|
||||
const editor = createTestEditor(doc, collection);
|
||||
|
||||
app.append(editor);
|
||||
await editor.updateComplete;
|
||||
|
||||
const debugMenu = new StarterDebugMenu();
|
||||
const docsPanel = new DocsPanel();
|
||||
const framePanel = new CustomFramePanel();
|
||||
const outlinePanel = new CustomOutlinePanel();
|
||||
const adapterPanel = new CustomAdapterPanel();
|
||||
const outlineViewer = new CustomOutlineViewer();
|
||||
const leftSidePanel = new LeftSidePanel();
|
||||
const commentPanel = new CommentPanel();
|
||||
const attachmentViewerPanel = new AttachmentViewerPanel();
|
||||
|
||||
docsPanel.editor = editor;
|
||||
framePanel.editor = editor;
|
||||
outlinePanel.editor = editor;
|
||||
outlineViewer.editor = editor;
|
||||
outlineViewer.toggleOutlinePanel = () => {
|
||||
outlinePanel.toggleDisplay();
|
||||
};
|
||||
adapterPanel.editor = editor;
|
||||
adapterPanel.transformerMiddlewares = [
|
||||
docLinkBaseURLMiddlewareBuilder(
|
||||
'https://example.com',
|
||||
editor.doc.workspace.id
|
||||
).get(),
|
||||
titleMiddleware(editor.doc.workspace.meta.docMetas),
|
||||
embedSyncedDocMiddleware('content'),
|
||||
defaultImageProxyMiddleware,
|
||||
];
|
||||
|
||||
debugMenu.collection = collection;
|
||||
debugMenu.editor = editor;
|
||||
debugMenu.outlinePanel = outlinePanel;
|
||||
debugMenu.outlineViewer = outlineViewer;
|
||||
debugMenu.framePanel = framePanel;
|
||||
debugMenu.leftSidePanel = leftSidePanel;
|
||||
debugMenu.docsPanel = docsPanel;
|
||||
debugMenu.adapterPanel = adapterPanel;
|
||||
|
||||
debugMenu.commentPanel = commentPanel;
|
||||
|
||||
commentPanel.editor = editor;
|
||||
|
||||
document.body.append(attachmentViewerPanel);
|
||||
document.body.append(outlinePanel);
|
||||
document.body.append(outlineViewer);
|
||||
document.body.append(framePanel);
|
||||
document.body.append(leftSidePanel);
|
||||
document.body.append(debugMenu);
|
||||
document.body.append(adapterPanel);
|
||||
|
||||
window.editor = editor;
|
||||
window.doc = doc;
|
||||
Object.defineProperty(globalThis, 'host', {
|
||||
get() {
|
||||
return document.querySelector('editor-host');
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'std', {
|
||||
get() {
|
||||
return document.querySelector('editor-host')?.std;
|
||||
},
|
||||
});
|
||||
|
||||
return editor;
|
||||
}
|
||||
99
blocksuite/playground/apps/starter/utils/collection.ts
Normal file
99
blocksuite/playground/apps/starter/utils/collection.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { StoreExtensionManager } from '@blocksuite/affine/ext-loader';
|
||||
import { AffineSchemas } from '@blocksuite/affine/schemas';
|
||||
import { nanoid, Schema, Transformer } from '@blocksuite/affine/store';
|
||||
import {
|
||||
createAutoIncrementIdGenerator,
|
||||
type DocCollectionOptions,
|
||||
TestWorkspace,
|
||||
} from '@blocksuite/affine/store/test';
|
||||
import {
|
||||
type BlobSource,
|
||||
BroadcastChannelAwarenessSource,
|
||||
BroadcastChannelDocSource,
|
||||
IndexedDBBlobSource,
|
||||
MemoryBlobSource,
|
||||
} from '@blocksuite/affine/sync';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { MockServerBlobSource } from '../../_common/sync/blob/mock-server.js';
|
||||
import type { InitFn } from '../data/utils.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const room = params.get('room');
|
||||
const isE2E = room?.startsWith('playwright');
|
||||
const blobSourceArgs = (params.get('blobSource') ?? '').split(',');
|
||||
|
||||
export function createStarterDocCollection(
|
||||
storeExtensionManager: StoreExtensionManager
|
||||
) {
|
||||
const collectionId = room ?? 'starter';
|
||||
const schema = new Schema();
|
||||
schema.register(AffineSchemas);
|
||||
const idGenerator = isE2E ? createAutoIncrementIdGenerator() : nanoid;
|
||||
|
||||
let docSources: DocCollectionOptions['docSources'];
|
||||
if (room) {
|
||||
docSources = {
|
||||
main: new BroadcastChannelDocSource(`broadcast-channel-${room}`),
|
||||
};
|
||||
}
|
||||
const id = room ?? `starter-${Math.random().toString(16).slice(2, 8)}`;
|
||||
|
||||
const blobSources = {
|
||||
main: new MemoryBlobSource(),
|
||||
shadows: [] as BlobSource[],
|
||||
} satisfies DocCollectionOptions['blobSources'];
|
||||
if (blobSourceArgs.includes('mock')) {
|
||||
blobSources.shadows.push(new MockServerBlobSource(collectionId));
|
||||
}
|
||||
if (blobSourceArgs.includes('idb')) {
|
||||
blobSources.shadows.push(new IndexedDBBlobSource(collectionId));
|
||||
}
|
||||
|
||||
const options: DocCollectionOptions = {
|
||||
id: collectionId,
|
||||
idGenerator,
|
||||
awarenessSources: [new BroadcastChannelAwarenessSource(id)],
|
||||
docSources,
|
||||
blobSources,
|
||||
};
|
||||
const collection = new TestWorkspace(options);
|
||||
collection.storeExtensions = storeExtensionManager.get('store');
|
||||
collection.start();
|
||||
|
||||
// debug info
|
||||
window.collection = collection;
|
||||
window.blockSchemas = AffineSchemas;
|
||||
window.job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
});
|
||||
window.Y = Y;
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
export async function initStarterDocCollection(collection: TestWorkspace) {
|
||||
// use built-in init function
|
||||
const functionMap = new Map<
|
||||
string,
|
||||
(collection: TestWorkspace, id: string) => Promise<void> | void
|
||||
>();
|
||||
Object.values(
|
||||
(await import('../data/index.js')) as Record<string, InitFn>
|
||||
).forEach(fn => functionMap.set(fn.id, fn));
|
||||
const init = params.get('init') || 'preset';
|
||||
if (functionMap.has(init)) {
|
||||
collection.meta.initialize();
|
||||
await functionMap.get(init)?.(collection, 'doc:home');
|
||||
const doc = collection.getDoc('doc:home');
|
||||
if (!doc?.loaded) {
|
||||
doc?.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
66
blocksuite/playground/apps/starter/utils/extensions.ts
Normal file
66
blocksuite/playground/apps/starter/utils/extensions.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
||||
import {
|
||||
CommunityCanvasTextFonts,
|
||||
DocModeProvider,
|
||||
EditorSettingExtension,
|
||||
FeatureFlagService,
|
||||
FontConfigExtension,
|
||||
ParseDocUrlExtension,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import type { ExtensionType, Store, Workspace } from '@blocksuite/affine/store';
|
||||
import { type TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
import { getTestViewManager } from '@blocksuite/integration-test/view';
|
||||
|
||||
import {
|
||||
mockDocModeService,
|
||||
mockEditorSetting,
|
||||
mockParseDocUrlService,
|
||||
} from '../../_common/mock-services';
|
||||
|
||||
const viewManager = getTestViewManager();
|
||||
|
||||
export function getTestCommonExtensions(
|
||||
editor: TestAffineEditorContainer
|
||||
): ExtensionType[] {
|
||||
return [
|
||||
FontConfigExtension(CommunityCanvasTextFonts),
|
||||
EditorSettingExtension({
|
||||
setting$: mockEditorSetting(),
|
||||
}),
|
||||
ParseDocUrlExtension(mockParseDocUrlService(editor.doc.workspace)),
|
||||
{
|
||||
setup: di => {
|
||||
di.override(DocModeProvider, mockDocModeService(editor));
|
||||
},
|
||||
},
|
||||
// CommentProviderExtension(mockCommentProvider()),
|
||||
];
|
||||
}
|
||||
|
||||
export function createTestEditor(store: Store, workspace: Workspace) {
|
||||
store
|
||||
.get(FeatureFlagService)
|
||||
.setFlag('enable_advanced_block_visibility', true);
|
||||
|
||||
const editor = document.createElement('affine-editor-container');
|
||||
|
||||
editor.autofocus = true;
|
||||
editor.doc = store;
|
||||
|
||||
const defaultExtensions = getTestCommonExtensions(editor);
|
||||
editor.pageSpecs = [...viewManager.get('page'), ...defaultExtensions];
|
||||
editor.edgelessSpecs = [...viewManager.get('edgeless'), ...defaultExtensions];
|
||||
|
||||
editor.std
|
||||
.get(RefNodeSlotsProvider)
|
||||
.docLinkClicked.subscribe(({ pageId: docId }) => {
|
||||
const target = workspace.getDoc(docId)?.getStore();
|
||||
if (!target) {
|
||||
throw new Error(`Failed to jump to doc ${docId}`);
|
||||
}
|
||||
target.load();
|
||||
editor.doc = target;
|
||||
});
|
||||
|
||||
return editor;
|
||||
}
|
||||
42
blocksuite/playground/apps/starter/utils/setup-playground.ts
Normal file
42
blocksuite/playground/apps/starter/utils/setup-playground.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { DocModeProvider } from '@blocksuite/affine/shared/services';
|
||||
import type { Workspace } from '@blocksuite/affine/store';
|
||||
import { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
||||
|
||||
import {
|
||||
getDocFromUrlParams,
|
||||
listenHashChange,
|
||||
setDocModeFromUrlParams,
|
||||
} from '../../_common/history.js';
|
||||
import { createTestApp } from './app.js';
|
||||
|
||||
export async function mountDefaultDocEditor(collection: Workspace) {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
const url = new URL(location.toString());
|
||||
const doc = getDocFromUrlParams(collection, url);
|
||||
|
||||
const editor = await createTestApp(doc, collection);
|
||||
|
||||
const modeService = editor.std.provider.get(DocModeProvider);
|
||||
editor.mode = modeService.getPrimaryMode(doc.id);
|
||||
setDocModeFromUrlParams(modeService, url.searchParams, doc.id);
|
||||
|
||||
// for multiple editor
|
||||
const params = new URLSearchParams(location.search);
|
||||
const init = params.get('init');
|
||||
if (init && init.startsWith('multiple-editor')) {
|
||||
app.childNodes.forEach(node => {
|
||||
if (node instanceof TestAffineEditorContainer) {
|
||||
node.style.flex = '1';
|
||||
if (init === 'multiple-editor-vertical') {
|
||||
node.style.overflow = 'auto';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
listenHashChange(collection, editor);
|
||||
|
||||
return editor;
|
||||
}
|
||||
54
blocksuite/playground/apps/starter/utils/test.ts
Normal file
54
blocksuite/playground/apps/starter/utils/test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Store, Workspace } from '@blocksuite/affine/store';
|
||||
|
||||
import { createTestApp } from './app';
|
||||
|
||||
export async function prepareTestApp(collection: Workspace) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const noInit = params.get('noInit') === 'true';
|
||||
|
||||
const store = await getStore(collection, noInit);
|
||||
store.load();
|
||||
if (!store.root) {
|
||||
await new Promise(resolve => {
|
||||
const subscription = store.slots.rootAdded.subscribe(value => {
|
||||
subscription.unsubscribe();
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await createTestApp(store, collection);
|
||||
}
|
||||
|
||||
async function getStore(
|
||||
collection: Workspace,
|
||||
noInit: boolean
|
||||
): Promise<Store> {
|
||||
if (!noInit) {
|
||||
collection.meta.initialize();
|
||||
const doc = collection.createDoc('doc:home').getStore();
|
||||
window.doc = doc;
|
||||
return doc;
|
||||
}
|
||||
|
||||
const doc = collection.docs.values().next().value;
|
||||
const firstDoc = doc?.getStore();
|
||||
if (firstDoc) {
|
||||
window.doc = firstDoc;
|
||||
return firstDoc;
|
||||
}
|
||||
|
||||
const { resolve, reject, promise } = Promise.withResolvers<Store>();
|
||||
collection.slots.docListUpdated.subscribe(() => {
|
||||
const doc = collection.docs.values().next().value;
|
||||
const firstDoc = doc?.getStore();
|
||||
if (!firstDoc) {
|
||||
reject(new Error(`Failed to get doc`));
|
||||
return;
|
||||
}
|
||||
window.doc = firstDoc;
|
||||
resolve(firstDoc);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
1
blocksuite/playground/apps/vite-env.d.ts
vendored
Normal file
1
blocksuite/playground/apps/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
3
blocksuite/playground/examples/README.md
Normal file
3
blocksuite/playground/examples/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# BlockSuite Playground Examples
|
||||
|
||||
This directory hosts a collection of simple examples designed for internal demonstration and debugging of specific functionalities within BlockSuite. They are built and deployed together with the [playground apps](../apps).
|
||||
19
blocksuite/playground/examples/inline/index.html
Normal file
19
blocksuite/playground/examples/inline/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html class="sl-theme-dark" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0/dist/themes/dark.css"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Simple Inline Editor Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<test-page></test-page>
|
||||
<script type="module">
|
||||
import './test-page';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
445
blocksuite/playground/examples/inline/test-page.ts
Normal file
445
blocksuite/playground/examples/inline/test-page.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import '@shoelace-style/shoelace';
|
||||
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { effects } from '@blocksuite/affine/std/effects';
|
||||
import {
|
||||
type AttributeRenderer,
|
||||
InlineEditor,
|
||||
ZERO_WIDTH_FOR_EMBED_NODE,
|
||||
} from '@blocksuite/affine/std/inline';
|
||||
import {
|
||||
type BaseTextAttributes,
|
||||
baseTextAttributes,
|
||||
} from '@blocksuite/affine/store';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import * as Y from 'yjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
effects();
|
||||
|
||||
function inlineTextStyles(
|
||||
props: BaseTextAttributes
|
||||
): ReturnType<typeof styleMap> {
|
||||
let textDecorations = '';
|
||||
if (props.underline) {
|
||||
textDecorations += 'underline';
|
||||
}
|
||||
if (props.strike) {
|
||||
textDecorations += ' line-through';
|
||||
}
|
||||
|
||||
let inlineCodeStyle = {};
|
||||
if (props.code) {
|
||||
inlineCodeStyle = {
|
||||
'font-family':
|
||||
'"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace',
|
||||
'line-height': 'normal',
|
||||
background: 'rgba(135,131,120,0.15)',
|
||||
color: '#EB5757',
|
||||
'border-radius': '3px',
|
||||
'font-size': '85%',
|
||||
padding: '0.2em 0.4em',
|
||||
};
|
||||
}
|
||||
|
||||
return styleMap({
|
||||
'font-weight': props.bold ? 'bold' : 'normal',
|
||||
'font-style': props.italic ? 'italic' : 'normal',
|
||||
'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
|
||||
...inlineCodeStyle,
|
||||
});
|
||||
}
|
||||
|
||||
const attributeRenderer: AttributeRenderer = ({ delta, selected }) => {
|
||||
// @ts-expect-error ignore
|
||||
if (delta.attributes?.embed) {
|
||||
return html`<span
|
||||
style=${styleMap({
|
||||
padding: '0 0.4em',
|
||||
border: selected ? '1px solid #eb763a' : '',
|
||||
background: 'rgba(135,131,120,0.15)',
|
||||
})}
|
||||
>@flrande<v-text .str=${ZERO_WIDTH_FOR_EMBED_NODE}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
const style = delta.attributes
|
||||
? inlineTextStyles(delta.attributes)
|
||||
: styleMap({});
|
||||
|
||||
return html`<span style=${style}
|
||||
><v-text .str=${delta.insert}></v-text
|
||||
></span>`;
|
||||
};
|
||||
|
||||
function toggleStyle(
|
||||
inlineEditor: InlineEditor,
|
||||
attrs: NonNullable<BaseTextAttributes>
|
||||
): void {
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = inlineEditor.rootElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltas = inlineEditor.getDeltasByInlineRange(inlineRange);
|
||||
let oldAttributes: NonNullable<BaseTextAttributes> = {};
|
||||
|
||||
for (const [delta] of deltas) {
|
||||
const attributes = delta.attributes;
|
||||
|
||||
if (!attributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
oldAttributes = { ...attributes };
|
||||
}
|
||||
|
||||
const newAttributes = Object.fromEntries(
|
||||
Object.entries(attrs).map(([k, v]) => {
|
||||
if (
|
||||
typeof v === 'boolean' &&
|
||||
v === (oldAttributes as Record<string, unknown>)[k]
|
||||
) {
|
||||
return [k, null];
|
||||
} else {
|
||||
return [k, v];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
inlineEditor.formatText(inlineRange, newAttributes, {
|
||||
mode: 'merge',
|
||||
});
|
||||
root.blur();
|
||||
|
||||
inlineEditor.setInlineRange(inlineRange);
|
||||
}
|
||||
|
||||
@customElement('test-rich-text')
|
||||
export class TestRichText extends ShadowlessElement {
|
||||
override firstUpdated() {
|
||||
this.contentEditable = 'true';
|
||||
this.style.outline = 'none';
|
||||
this.inlineEditor.mount(this._container, this);
|
||||
|
||||
this.inlineEditor.slots.textChange.subscribe(() => {
|
||||
const el = this.querySelector('.y-text');
|
||||
if (el) {
|
||||
const text = this.inlineEditor.yText.toDelta();
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = JSON.stringify(text);
|
||||
el.replaceChildren(span);
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
const inlineRange = this.inlineEditor.inlineRange$.value;
|
||||
const el = this.querySelector('.v-range');
|
||||
if (el && inlineRange) {
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = JSON.stringify(inlineRange);
|
||||
el.replaceChildren(span);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<style>
|
||||
test-rich-text {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rich-text-container {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
'SFMono-Regular', Menlo, Consolas, 'PT Mono', 'Liberation Mono',
|
||||
Courier, monospace;
|
||||
line-height: normal;
|
||||
background: rgba(135, 131, 120, 0.15);
|
||||
color: #eb5757;
|
||||
border-radius: 3px;
|
||||
font-size: 85%;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
|
||||
.v-range,
|
||||
.y-text {
|
||||
font-family:
|
||||
'SFMono-Regular', Menlo, Consolas, 'PT Mono', 'Liberation Mono',
|
||||
Courier, monospace;
|
||||
line-height: normal;
|
||||
background: rgba(135, 131, 120, 0.15);
|
||||
}
|
||||
|
||||
.v-range,
|
||||
.y-text > span {
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
<div class="rich-text-container"></div>
|
||||
<div contenteditable="false" class="v-range"></div>
|
||||
<div contenteditable="false" class="y-text"></div>`;
|
||||
}
|
||||
|
||||
@query('.rich-text-container')
|
||||
private accessor _container!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: InlineEditor;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor undoManager!: Y.UndoManager;
|
||||
}
|
||||
|
||||
const TEXT_ID = 'inline-editor';
|
||||
const yDocA = new Y.Doc();
|
||||
const yDocB = new Y.Doc();
|
||||
|
||||
yDocA.on('update', update => {
|
||||
Y.applyUpdate(yDocB, update);
|
||||
});
|
||||
|
||||
yDocB.on('update', update => {
|
||||
Y.applyUpdate(yDocA, update);
|
||||
});
|
||||
|
||||
@customElement('custom-toolbar')
|
||||
export class CustomToolbar extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
.custom-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('pointerdown', e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const boldButton = this.querySelector('.bold');
|
||||
const italicButton = this.querySelector('.italic');
|
||||
const underlineButton = this.querySelector('.underline');
|
||||
const strikeButton = this.querySelector('.strike');
|
||||
const code = this.querySelector('.code');
|
||||
const embed = this.querySelector('.embed');
|
||||
const resetButton = this.querySelector('.reset');
|
||||
const undoButton = this.querySelector('.undo');
|
||||
const redoButton = this.querySelector('.redo');
|
||||
|
||||
if (
|
||||
!boldButton ||
|
||||
!italicButton ||
|
||||
!underlineButton ||
|
||||
!strikeButton ||
|
||||
!code ||
|
||||
!embed ||
|
||||
!resetButton ||
|
||||
!undoButton ||
|
||||
!redoButton
|
||||
) {
|
||||
throw new Error('Cannot find button');
|
||||
}
|
||||
|
||||
const undoManager = new Y.UndoManager(this.inlineEditor.yText, {
|
||||
trackedOrigins: new Set([this.inlineEditor.yText.doc?.clientID]),
|
||||
});
|
||||
|
||||
addEventListener('keydown', e => {
|
||||
if (
|
||||
e instanceof KeyboardEvent &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.key === 'z'
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
undoManager.redo();
|
||||
} else {
|
||||
undoManager.undo();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
undoButton.addEventListener('click', () => {
|
||||
undoManager.undo();
|
||||
});
|
||||
redoButton.addEventListener('click', () => {
|
||||
undoManager.redo();
|
||||
});
|
||||
|
||||
boldButton.addEventListener('click', () => {
|
||||
undoManager.stopCapturing();
|
||||
toggleStyle(this.inlineEditor, { bold: true });
|
||||
});
|
||||
italicButton.addEventListener('click', () => {
|
||||
undoManager.stopCapturing();
|
||||
toggleStyle(this.inlineEditor, { italic: true });
|
||||
});
|
||||
underlineButton.addEventListener('click', () => {
|
||||
undoManager.stopCapturing();
|
||||
toggleStyle(this.inlineEditor, { underline: true });
|
||||
});
|
||||
strikeButton.addEventListener('click', () => {
|
||||
undoManager.stopCapturing();
|
||||
toggleStyle(this.inlineEditor, { strike: true });
|
||||
});
|
||||
code.addEventListener('click', () => {
|
||||
undoManager.stopCapturing();
|
||||
toggleStyle(this.inlineEditor, { code: true });
|
||||
});
|
||||
embed.addEventListener('click', () => {
|
||||
undoManager.stopCapturing();
|
||||
// @ts-expect-error ignore
|
||||
toggleStyle(this.inlineEditor, { embed: true });
|
||||
});
|
||||
resetButton.addEventListener('click', () => {
|
||||
undoManager.stopCapturing();
|
||||
const rangeStatic = this.inlineEditor.getInlineRange();
|
||||
if (!rangeStatic) {
|
||||
return;
|
||||
}
|
||||
this.inlineEditor.resetText(rangeStatic);
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="custom-toolbar">
|
||||
<sl-button class="bold">bold</sl-button>
|
||||
<sl-button class="italic">italic</sl-button>
|
||||
<sl-button class="underline">underline</sl-button>
|
||||
<sl-button class="strike">strike</sl-button>
|
||||
<sl-button class="code">code</sl-button>
|
||||
<sl-button class="embed">embed</sl-button>
|
||||
<sl-button class="reset">reset</sl-button>
|
||||
<sl-button class="undo">undo</sl-button>
|
||||
<sl-button class="redo">redo</sl-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: InlineEditor;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor undoManager!: Y.UndoManager;
|
||||
}
|
||||
|
||||
@customElement('test-page')
|
||||
export class TestPage extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
.container {
|
||||
display: grid;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editors {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
padding: 20px;
|
||||
background-color: #202124;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
grid-gap: 20px;
|
||||
}
|
||||
|
||||
.editors > div {
|
||||
height: 600px;
|
||||
max-width: 400px;
|
||||
display: grid;
|
||||
grid-template-rows: 150px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
`;
|
||||
|
||||
private _editorA: InlineEditor | null = null;
|
||||
|
||||
private _editorB: InlineEditor | null = null;
|
||||
|
||||
private _undoManagerA: Y.UndoManager | null = null;
|
||||
|
||||
private _undoManagerB: Y.UndoManager | null = null;
|
||||
|
||||
override firstUpdated() {
|
||||
const textA = yDocA.getText(TEXT_ID);
|
||||
this._editorA = new InlineEditor<
|
||||
BaseTextAttributes & {
|
||||
embed?: true;
|
||||
}
|
||||
>(textA, {
|
||||
isEmbed: delta => !!delta.attributes?.embed,
|
||||
});
|
||||
this._editorA.setAttributeSchema(
|
||||
baseTextAttributes.extend({
|
||||
embed: z.literal(true).optional().catch(undefined),
|
||||
})
|
||||
);
|
||||
this._editorA.setAttributeRenderer(attributeRenderer);
|
||||
this._undoManagerA = new Y.UndoManager(textA, {
|
||||
trackedOrigins: new Set([textA.doc?.clientID]),
|
||||
});
|
||||
|
||||
const textB = yDocB.getText(TEXT_ID);
|
||||
this._editorB = new InlineEditor(textB);
|
||||
this._undoManagerB = new Y.UndoManager(textB, {
|
||||
trackedOrigins: new Set([textB.doc?.clientID]),
|
||||
});
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this._editorA) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="editors">
|
||||
<div class="doc-a">
|
||||
<custom-toolbar
|
||||
.inlineEditor=${this._editorA}
|
||||
.undoManager=${this._undoManagerA}
|
||||
></custom-toolbar>
|
||||
<test-rich-text
|
||||
.inlineEditor=${this._editorA}
|
||||
.undoManager=${this._undoManagerA!}
|
||||
></test-rich-text>
|
||||
</div>
|
||||
<div class="doc-b">
|
||||
<custom-toolbar
|
||||
.inlineEditor=${this._editorB}
|
||||
.undoManager=${this._undoManagerB!}
|
||||
></custom-toolbar>
|
||||
<test-rich-text
|
||||
.inlineEditor=${this._editorB}
|
||||
.undoManager=${this._undoManagerB}
|
||||
></test-rich-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
69
blocksuite/playground/index.html
Normal file
69
blocksuite/playground/index.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Kalam&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BlockSuite Playground</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--affine-white-90);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
doc-title {
|
||||
margin-top: 78px;
|
||||
}
|
||||
|
||||
:root .tp-dfwv {
|
||||
position: fixed;
|
||||
overflow: scroll;
|
||||
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
doc-title {
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
affine-editor-container {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-page-viewport {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.playground-page-editor-container {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="module" src="./apps/starter/main.ts"></script>
|
||||
<div id="app">
|
||||
<div id="inspector"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
43
blocksuite/playground/package.json
Normal file
43
blocksuite/playground/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@blocksuite/playground",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev:hmr": "WC_HMR=1 vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/integration-test": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@shoelace-style/shoelace": "2.20.1",
|
||||
"@toeverything/pdf-viewer": "^0.1.1",
|
||||
"@toeverything/pdfium": "^0.1.1",
|
||||
"katex": "^0.16.27",
|
||||
"lit": "^3.2.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"tweakpane": "^4.0.5",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.27",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tweakpane/core": "^2.0.4",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plugin-web-components-hmr": "^0.1.3"
|
||||
},
|
||||
"version": "0.27.5"
|
||||
}
|
||||
1
blocksuite/playground/public/logo.svg
Normal file
1
blocksuite/playground/public/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.cls-1{fill:#000000;}</style></defs><g id="_20230202-pull_request"><path class="cls-1" d="m512,133.82L342.5,0,0,67.88v310.26l169.56,133.86,342.44-67.88V133.82Zm-203.39-84.81v89.67l51.84-10.27v-56.25l74.87,59.09-249.93,49.53-108.71-85.8,231.93-45.96ZM51.84,133.32l99.71,78.7v93.92l51.84-10.27v-76.15l256.78-50.89v210.06l-99.71-78.7v-93.92l-51.84,10.27v76.16l-256.78,50.89v-210.06Zm151.55,329.67v-89.67l-51.84,10.27v56.25l-74.87-59.09,249.93-49.53,108.71,85.8-231.93,45.96Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 608 B |
BIN
blocksuite/playground/public/test-card-1.png
Normal file
BIN
blocksuite/playground/public/test-card-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
blocksuite/playground/public/test-card-2.png
Normal file
BIN
blocksuite/playground/public/test-card-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
65
blocksuite/playground/scripts/hmr-plugin/fine-tune.ts
Normal file
65
blocksuite/playground/scripts/hmr-plugin/fine-tune.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/* oxlint-disable import-x-js/no-extraneous-dependencies */
|
||||
import path from 'node:path';
|
||||
|
||||
import { init, parse } from 'es-module-lexer';
|
||||
import MagicString from 'magic-string';
|
||||
import micromatch from 'micromatch';
|
||||
import type { Plugin } from 'vite';
|
||||
const isMatch = micromatch.isMatch;
|
||||
|
||||
export function fineTuneHmr({
|
||||
include,
|
||||
exclude,
|
||||
}: {
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
}): Plugin {
|
||||
let root = '';
|
||||
const plugin: Plugin = {
|
||||
name: 'add-hot-for-pure-exports',
|
||||
apply: 'serve',
|
||||
configResolved(config) {
|
||||
root = config.root;
|
||||
},
|
||||
async configureServer() {
|
||||
await init;
|
||||
},
|
||||
transform: (code, id) => {
|
||||
// only handle js/ts files
|
||||
const includeGlob = include.map(i => path.resolve(root, i));
|
||||
const excludeGlob = exclude.map(i => path.resolve(root, i));
|
||||
const isInScope = isMatch(id, includeGlob) && !isMatch(id, excludeGlob);
|
||||
if (!isInScope) return;
|
||||
if (!(id.endsWith('.js') || id.endsWith('.ts'))) return;
|
||||
// only handle files which not contains Lit elements
|
||||
if (code.includes('import.meta.hot')) return;
|
||||
|
||||
const [imports, exports] = parse(code, id);
|
||||
if (exports.length === 0 && imports.length > 0) {
|
||||
const modules = imports.map(i => i.n);
|
||||
const modulesEndsWithTs = modules
|
||||
.filter(Boolean)
|
||||
.map(m => m!.replace(/\.js$/, '.ts'));
|
||||
const preamble = `
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(${JSON.stringify(
|
||||
modulesEndsWithTs
|
||||
)}, data => {
|
||||
// some update logic
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
const s = new MagicString(code);
|
||||
s.prepend(preamble + '\n');
|
||||
return {
|
||||
code: s.toString(),
|
||||
map: s.generateMap({ hires: true, source: id, includeContent: true }),
|
||||
};
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
return plugin;
|
||||
}
|
||||
38
blocksuite/playground/scripts/hmr-plugin/index.ts
Normal file
38
blocksuite/playground/scripts/hmr-plugin/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
hmrPlugin as wcHmrPlugin,
|
||||
presets,
|
||||
} from 'vite-plugin-web-components-hmr';
|
||||
|
||||
import { fineTuneHmr } from './fine-tune.js';
|
||||
|
||||
const customLitPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../blocks/src/_legacy/index.js'
|
||||
);
|
||||
|
||||
const include = ['../blocks/src/**/*'];
|
||||
const exclude = ['**/*/node_modules/**/*'];
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export const hmrPlugin = process.env.WC_HMR
|
||||
? [
|
||||
wcHmrPlugin({
|
||||
include,
|
||||
exclude,
|
||||
presets: [presets.lit],
|
||||
decorators: [{ name: 'customElement', import: 'lit/decorators.js' }],
|
||||
baseClasses: [
|
||||
{
|
||||
name: 'ShadowlessElement',
|
||||
import: customLitPath,
|
||||
},
|
||||
],
|
||||
}),
|
||||
fineTuneHmr({
|
||||
include,
|
||||
exclude,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
16
blocksuite/playground/style.css
Normal file
16
blocksuite/playground/style.css
Normal file
@@ -0,0 +1,16 @@
|
||||
@import '@toeverything/theme/style.css';
|
||||
@import '@toeverything/theme/fonts.css';
|
||||
|
||||
@import 'katex/dist/katex.min.css';
|
||||
|
||||
@font-face {
|
||||
font-family: 'color-emoji';
|
||||
src:
|
||||
local('Apple Color Emoji'), local('Segoe UI Emoji'),
|
||||
local('Segoe UI Symbol'), local('Noto Color Emoji');
|
||||
unicode-range: U+1F000-1F644, U+203C-3299;
|
||||
}
|
||||
|
||||
.dg > ul {
|
||||
overflow: scroll;
|
||||
}
|
||||
17
blocksuite/playground/tsconfig.json
Normal file
17
blocksuite/playground/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./apps", "./examples"],
|
||||
"references": [
|
||||
{ "path": "../affine/all" },
|
||||
{ "path": "../affine/model" },
|
||||
{ "path": "../affine/shared" },
|
||||
{ "path": "../affine/data-view" },
|
||||
{ "path": "../integration-test" }
|
||||
]
|
||||
}
|
||||
11
blocksuite/playground/tsconfig.node.json
Normal file
11
blocksuite/playground/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"rootDir": ".",
|
||||
"outDir": "./lib",
|
||||
"tsBuildInfoFile": "./lib/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["vite.config.ts", "./scripts"]
|
||||
}
|
||||
114
blocksuite/playground/vite.config.ts
Normal file
114
blocksuite/playground/vite.config.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { cpus } from 'node:os';
|
||||
import path, { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||
import type { Plugin } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import istanbul from 'vite-plugin-istanbul';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
|
||||
import { hmrPlugin } from './scripts/hmr-plugin';
|
||||
|
||||
const enableIstanbul = !!process.env.COVERAGE;
|
||||
|
||||
export function sourcemapExclude(): Plugin {
|
||||
return {
|
||||
name: 'sourcemap-exclude',
|
||||
transform(code: string, id: string) {
|
||||
if (id.includes('node_modules') && !id.includes('@blocksuite')) {
|
||||
return {
|
||||
code,
|
||||
// https://github.com/rollup/rollup/blob/master/docs/plugin-development/index.md#source-code-transformations
|
||||
map: { mappings: '' },
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const clearSiteDataPlugin = () =>
|
||||
({
|
||||
name: 'clear-site-data',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url === '/Clear-Site-Data') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Clear-Site-Data', '"*"');
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
}) as Plugin;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
process.env = { ...process.env, ...loadEnv(mode, __dirname, '') };
|
||||
|
||||
return {
|
||||
envDir: __dirname,
|
||||
define: {
|
||||
'import.meta.env.PLAYGROUND_SERVER': JSON.stringify(
|
||||
process.env.PLAYGROUND_SERVER ?? 'http://localhost:8787'
|
||||
),
|
||||
'import.meta.env.PLAYGROUND_WS': JSON.stringify(
|
||||
process.env.PLAYGROUND_WS ?? 'ws://localhost:8787'
|
||||
),
|
||||
},
|
||||
plugins: [
|
||||
hmrPlugin,
|
||||
sourcemapExclude(),
|
||||
enableIstanbul &&
|
||||
istanbul({
|
||||
cwd: fileURLToPath(new URL('../..', import.meta.url)),
|
||||
include: ['packages/**/src/*'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'tests',
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
],
|
||||
forceBuildInstrument: true,
|
||||
}),
|
||||
wasm(),
|
||||
vanillaExtractPlugin(),
|
||||
clearSiteDataPlugin(),
|
||||
],
|
||||
esbuild: {
|
||||
target: 'es2018',
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
cache: false,
|
||||
maxParallelFileOps: Math.max(1, cpus().length - 1),
|
||||
onwarn(warning, defaultHandler) {
|
||||
if (
|
||||
warning.code &&
|
||||
['EVAL', 'SOURCEMAP_ERROR'].includes(warning.code)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
defaultHandler(warning);
|
||||
},
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
'examples/inline': resolve(__dirname, 'examples/inline/index.html'),
|
||||
},
|
||||
treeshake: true,
|
||||
output: {
|
||||
sourcemapIgnoreList: relativeSourcePath => {
|
||||
const normalizedPath = path.normalize(relativeSourcePath);
|
||||
return normalizedPath.includes('node_modules');
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user