refactor(editor): rename presets to integration test (#10340)

This commit is contained in:
Saul-Mirone
2025-02-21 06:26:03 +00:00
parent f79324b6a1
commit f3218ab3bc
116 changed files with 156 additions and 210 deletions

View File

@@ -0,0 +1,123 @@
import { ShadowlessElement, TextSelection } from '@blocksuite/block-std';
import type { RichText } from '@blocksuite/blocks';
import { WithDisposable } from '@blocksuite/global/utils';
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;
}
}

View File

@@ -0,0 +1,160 @@
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
import type { EditorHost, TextSelection } from '@blocksuite/block-std';
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.doc.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.doc.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.doc.spaceDoc
);
const startBlock = this.host.view.getBlock(start.id);
const endIndex = Y.createAbsolutePositionFromRelativePosition(
end.index,
this.host.doc.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,
},
},
};
}
}

View File

@@ -0,0 +1,116 @@
import { ShadowlessElement, TextSelection } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
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;
}
}

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

View File

@@ -0,0 +1,2 @@
export * from './comment-manager.js';
export * from './comment-panel.js';