feat(editor): add inline packages (#11048)

This commit is contained in:
Saul-Mirone committed 2025-03-20 13:47:35 +00:00
1 parent aa620af40f
commit e5e429e7b2
170 files changed
+1337 -804

No files matched your search

@@ -0,0 +1,305 @@
import type { EditorIconButton } from '@blocksuite/affine-components/toolbar';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import {
isValidUrl,
normalizeUrl,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, TextSelection } from '@blocksuite/block-std';
import type { InlineRange } from '@blocksuite/block-std/inline';
import { WithDisposable } from '@blocksuite/global/lit';
import { DoneIcon } from '@blocksuite/icons/lit';
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
import { html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { linkPopupStyle } from './styles';
export class LinkPopup extends WithDisposable(LitElement) {
static override styles = linkPopupStyle;
private _bodyOverflowStyle = '';
private readonly _createTemplate = () => {
this.updateComplete
.then(() => {
this.linkInput?.focus();
this._updateConfirmBtn();
})
.catch(console.error);
return html`
<div class="affine-link-popover create">
<input
id="link-input"
class="affine-link-popover-input"
type="text"
spellcheck="false"
placeholder="Paste or type a link"
@paste=${this._updateConfirmBtn}
@input=${this._updateConfirmBtn}
/>
${this._confirmBtnTemplate()}
</div>
`;
};
private readonly _editTemplate = () => {
this.updateComplete
.then(() => {
if (
!this.textInput ||
!this.linkInput ||
!this.currentText ||
!this.currentLink
)
return;
this.textInput.value = this.currentText;
this.linkInput.value = this.currentLink;
this.textInput.select();
this._updateConfirmBtn();
})
.catch(console.error);
return html`
<div class="affine-link-edit-popover">
<div class="affine-edit-area text">
<input
class="affine-edit-input"
id="text-input"
type="text"
placeholder="Enter text"
@input=${this._updateConfirmBtn}
/>
<label class="affine-edit-label" for="text-input">Text</label>
</div>
<div class="affine-edit-area link">
<input
id="link-input"
class="affine-edit-input"
type="text"
spellcheck="false"
placeholder="Paste or type a link"
@input=${this._updateConfirmBtn}
/>
<label class="affine-edit-label" for="link-input">Link</label>
</div>
${this._confirmBtnTemplate()}
</div>
`;
};
get currentLink() {
return this.inlineEditor.getFormat(this.targetInlineRange).link;
}
get currentText() {
return this.inlineEditor.yTextString.slice(
this.targetInlineRange.index,
this.targetInlineRange.index + this.targetInlineRange.length
);
}
private _confirmBtnTemplate() {
return html`
<editor-icon-button
class="affine-confirm-button"
.iconSize="${'24px'}"
.disabled=${true}
@click=${this._onConfirm}
>
${DoneIcon()}
</editor-icon-button>
`;
}
private _onConfirm() {
if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) return;
if (!this.linkInput) return;
const linkInputValue = this.linkInput.value;
if (!linkInputValue || !isValidUrl(linkInputValue)) return;
const link = normalizeUrl(linkInputValue);
if (this.type === 'create') {
this.inlineEditor.formatText(this.targetInlineRange, {
link: link,
reference: null,
});
this.inlineEditor.setInlineRange(this.targetInlineRange);
} else if (this.type === 'edit') {
const text = this.textInput?.value ?? link;
this.inlineEditor.insertText(this.targetInlineRange, text, {
link: link,
reference: null,
});
this.inlineEditor.setInlineRange({
index: this.targetInlineRange.index,
length: text.length,
});
}
const textSelection = this.std.host.selection.find(TextSelection);
if (textSelection) {
this.std.range.syncTextSelectionToRange(textSelection);
}
this.abortController.abort();
}
private _onKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (!e.isComposing) {
if (e.key === 'Escape') {
e.preventDefault();
this.abortController.abort();
this.std.host.selection.clear();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
this._onConfirm();
}
}
}
private _updateConfirmBtn() {
if (!this.confirmButton) {
return;
}
const link = this.linkInput?.value.trim();
const disabled = !(link && isValidUrl(link));
this.confirmButton.disabled = disabled;
this.confirmButton.active = !disabled;
this.confirmButton.requestUpdate();
}
override connectedCallback() {
super.connectedCallback();
if (this.targetInlineRange.length === 0) {
return;
}
// disable body scroll
this._bodyOverflowStyle = document.body.style.overflow;
document.body.style.overflow = 'hidden';
this.disposables.add({
dispose: () => {
document.body.style.overflow = this._bodyOverflowStyle;
},
});
}
override firstUpdated() {
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
this.disposables.addFromEvent(this.overlayMask, 'click', e => {
e.stopPropagation();
this.std.host.selection.setGroup('note', []);
this.abortController.abort();
});
}
override render() {
return html`
<div class="overlay-root">
<div class="overlay-mask"></div>
<div class="popover-container">
${choose(this.type, [
['create', this._createTemplate],
['edit', this._editTemplate],
])}
</div>
<div class="mock-selection-container"></div>
</div>
`;
}
override updated() {
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
if (!range) {
return;
}
const domRects = range.getClientRects();
Object.values(domRects).forEach(domRect => {
if (!this.mockSelectionContainer) {
return;
}
const mockSelection = document.createElement('div');
mockSelection.classList.add('mock-selection');
mockSelection.style.left = `${domRect.left}px`;
mockSelection.style.top = `${domRect.top}px`;
mockSelection.style.width = `${domRect.width}px`;
mockSelection.style.height = `${domRect.height}px`;
this.mockSelectionContainer.append(mockSelection);
});
const visualElement = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
const popover = this.popoverContainer;
computePosition(visualElement, popover, {
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
popover.style.left = `${x}px`;
popover.style.top = `${y}px`;
})
.catch(console.error);
}
@property({ attribute: false })
accessor abortController!: AbortController;
@query('.affine-confirm-button')
accessor confirmButton: EditorIconButton | null = null;
@property({ attribute: false })
accessor inlineEditor!: AffineInlineEditor;
@query('#link-input')
accessor linkInput: HTMLInputElement | null = null;
@query('.mock-selection-container')
accessor mockSelectionContainer!: HTMLDivElement;
@query('.overlay-mask')
accessor overlayMask!: HTMLDivElement;
@query('.popover-container')
accessor popoverContainer!: HTMLDivElement;
@property({ attribute: false })
accessor targetInlineRange!: InlineRange;
@query('#text-input')
accessor textInput: HTMLInputElement | null = null;
@property()
accessor type: 'create' | 'edit' = 'create';
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -0,0 +1,158 @@
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
const editLinkStyle = css`
.affine-link-edit-popover {
${PANEL_BASE};
display: grid;
grid-template-columns: auto auto;
grid-template-rows: repeat(2, 1fr);
grid-template-areas:
'text-area .'
'link-area btn';
justify-items: center;
align-items: center;
width: 320px;
gap: 8px 12px;
padding: 12px;
box-sizing: content-box;
}
.affine-link-edit-popover label {
box-sizing: border-box;
color: var(--affine-icon-color);
${FONT_XS};
font-weight: 400;
}
.affine-link-edit-popover input {
color: inherit;
padding: 0;
border: none;
background: transparent;
color: var(--affine-text-primary-color);
${FONT_XS};
}
.affine-link-edit-popover input::placeholder {
color: var(--affine-placeholder-color);
}
input:focus {
outline: none;
}
.affine-link-edit-popover input:focus ~ label,
.affine-link-edit-popover input:active ~ label {
color: var(--affine-primary-color);
}
.affine-edit-area {
width: 280px;
padding: 4px 10px;
display: grid;
gap: 8px;
grid-template-columns: 26px auto;
grid-template-rows: repeat(1, 1fr);
grid-template-areas: 'label input';
user-select: none;
box-sizing: border-box;
border: 1px solid var(--affine-border-color);
box-sizing: border-box;
outline: none;
border-radius: 4px;
background: transparent;
}
.affine-edit-area:focus-within {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
}
.affine-edit-area.text {
grid-area: text-area;
}
.affine-edit-area.link {
grid-area: link-area;
}
.affine-edit-label {
grid-area: label;
}
.affine-edit-input {
grid-area: input;
}
.affine-confirm-button {
grid-area: btn;
user-select: none;
}
`;
export const linkPopupStyle = css`
:host {
box-sizing: border-box;
}
.mock-selection {
position: absolute;
background-color: rgba(35, 131, 226, 0.28);
}
.popover-container {
z-index: var(--affine-z-index-popover);
animation: affine-popover-fade-in 0.2s ease;
position: absolute;
}
@keyframes affine-popover-fade-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.overlay-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: var(--affine-z-index-popover);
}
.affine-link-popover.create {
${PANEL_BASE};
gap: 12px;
padding: 12px;
color: var(--affine-text-primary-color);
}
.affine-link-popover-input {
min-width: 280px;
height: 30px;
box-sizing: border-box;
padding: 4px 10px;
background: var(--affine-white-10);
border-radius: 4px;
border-width: 1px;
border-style: solid;
border-color: var(--affine-border-color);
color: var(--affine-text-primary-color);
${FONT_XS};
}
.affine-link-popover-input::placeholder {
color: var(--affine-placeholder-color);
}
.affine-link-popover-input:focus {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
}
${editLinkStyle}
`;
@@ -0,0 +1,24 @@
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { InlineRange } from '@blocksuite/block-std/inline';
import { LinkPopup } from './link-popup';
export function toggleLinkPopup(
std: BlockStdScope,
type: LinkPopup['type'],
inlineEditor: AffineInlineEditor,
targetInlineRange: InlineRange,
abortController: AbortController
): LinkPopup {
const popup = new LinkPopup();
popup.std = std;
popup.type = type;
popup.inlineEditor = inlineEditor;
popup.targetInlineRange = targetInlineRange;
popup.abortController = abortController;
document.body.append(popup);
return popup;
}