feat(editor): add inline packages (#11048)
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user