import { assertExists } from '@blockeditor/global/utils'; import type { InlineRange } from '@blockeditor/inline/types'; import type { BlockElement } from '@blockeditor/lit'; import { WithDisposable } from '@blockeditor/lit'; import { computePosition, flip, inline, offset, shift } from '@floating-ui/dom'; import { html, LitElement, nothing } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import type { EmbedOptions, PageService, } from '../../../../../../page-block/page-service.js'; import type { IconButton } from '../../../../../components/button.js'; import { createLitPortal } from '../../../../../components/portal.js'; import { toast } from '../../../../../components/toast.js'; import { BLOCK_ID_ATTR } from '../../../../../consts.js'; import { BookmarkIcon, MoreVerticalIcon } from '../../../../../icons/index.js'; import { ConfirmIcon, CopyIcon, EditIcon, EmbedWebIcon, LinkIcon, UnlinkIcon, } from '../../../../../icons/text.js'; import { isValidUrl, normalizeUrl } from '../../../../../utils/url.js'; import type { AffineInlineEditor } from '../../../affine-inline-specs.js'; import { LinkPopupMoreMenu } from './link-popup-more-menu-popup.js'; import { linkPopupStyle } from './styles.js'; @customElement('link-popup') export class LinkPopup extends WithDisposable(LitElement) { static override styles = linkPopupStyle; @property() type: 'create' | 'edit' | 'view' = 'create'; @property({ attribute: false }) inlineEditor!: AffineInlineEditor; @property({ attribute: false }) targetInlineRange!: InlineRange; @property({ attribute: false }) abortController!: AbortController; @query('#text-input') textInput?: HTMLInputElement; @query('#link-input') linkInput?: HTMLInputElement; @query('.affine-link-popover-container') popupContainer!: HTMLDivElement; @query('.mock-selection-container') mockSelectionContainer!: HTMLDivElement; @query('.affine-confirm-button') confirmButton?: IconButton; private _bodyOverflowStyle = ''; private _moreMenuAbortController: AbortController | null = null; private _embedOptions: EmbedOptions | null = null; override connectedCallback() { super.connectedCallback(); if (this.targetInlineRange.length === 0) { throw new Error('Cannot toggle link popup on empty range'); } if (this.type === 'edit' || this.type === 'create') { // disable body scroll this._bodyOverflowStyle = document.body.style.overflow; document.body.style.overflow = 'hidden'; this.disposables.add({ dispose: () => { document.body.style.overflow = this._bodyOverflowStyle; }, }); } const parent = this.blockElement.page.getParent(this.blockElement.model); assertExists(parent); this.disposables.add( parent.childrenUpdated.on(() => { const children = parent.children; if (children.includes(this.blockElement.model)) return; this.abortController.abort(); }) ); } protected override firstUpdated() { if (!this.linkInput) return; this._disposables.addFromEvent(this.linkInput, 'copy', e => { e.stopPropagation(); }); this._disposables.addFromEvent(this.linkInput, 'cut', e => { e.stopPropagation(); }); this._disposables.addFromEvent(this.linkInput, 'paste', e => { e.stopPropagation(); }); } override updated() { assertExists(this.popupContainer); const range = this.inlineEditor.toDomRange(this.targetInlineRange); assertExists(range); if (this.type !== 'view') { const domRects = range.getClientRects(); Object.values(domRects).forEach(domRect => { const mockSelection = document.createElement('div'); mockSelection.classList.add('mock-selection', 'blockeditor-overlay'); mockSelection.style.left = `${domRect.left}px`; mockSelection.style.top = `${domRect.top}px`; mockSelection.style.width = `${domRect.width}px`; mockSelection.style.height = `${domRect.height}px`; assertExists(this.mockSelectionContainer); this.mockSelectionContainer.appendChild(mockSelection); }); } const visualElement = { getBoundingClientRect: () => range.getBoundingClientRect(), getClientRects: () => range.getClientRects(), }; computePosition(visualElement, this.popupContainer, { middleware: [ offset(10), inline(), shift({ padding: 6, }), ], }) .then(({ x, y }) => { const popupContainer = this.popupContainer; if (!popupContainer) return; popupContainer.style.left = `${x}px`; popupContainer.style.top = `${y}px`; }) .catch(console.error); } private get _pageService() { const pageService = this.std.spec.getService( 'affine:page' ) as PageService | null; assertExists(pageService); return pageService; } get host() { return this.blockElement.host; } get std() { return this.blockElement.std; } get blockElement() { const blockElement = this.inlineEditor.rootElement.closest( `[${BLOCK_ID_ATTR}]` ); assertExists(blockElement); return blockElement; } get currentText() { return this.inlineEditor.yTextString.slice( this.targetInlineRange.index, this.targetInlineRange.index + this.targetInlineRange.length ); } get currentLink() { const link = this.inlineEditor.getFormat(this.targetInlineRange).link; assertExists(link); return link; } private get _isBookmarkAllowed() { const blockElement = this.blockElement; const schema = blockElement.host.page.schema; const parent = blockElement.host.page.getParent(blockElement.model); assertExists(parent); const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark'); assertExists(bookmarkSchema); const parentSchema = schema.flavourSchemaMap.get(parent.flavour); assertExists(parentSchema); try { schema.validateSchema(bookmarkSchema, parentSchema); } catch { return false; } return true; } private get _canConvertToEmbedView() { return this._embedOptions?.viewType === 'embed'; } private _onConfirm() { if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) return; assertExists(this.linkInput); 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, }, { mode: 'replace' } ); 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, }); } this.abortController.abort(); } private _copyUrl() { navigator.clipboard.writeText(this.currentLink).catch(console.error); toast(this.host, 'Copied link to clipboard'); this.abortController.abort(); } private _convertToCardView() { if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { return; } let targetFlavour = 'affine:bookmark'; if (this._embedOptions && this._embedOptions.viewType === 'card') { targetFlavour = this._embedOptions.flavour; } const blockElement = this.blockElement; const url = this.currentLink; const title = this.currentText; const props = { url, title: title === url ? '' : title, }; const page = blockElement.host.page; const parent = page.getParent(blockElement.model); assertExists(parent); const index = parent.children.indexOf(blockElement.model); page.addBlock(targetFlavour, props, parent, index + 1); const totalTextLength = this.inlineEditor.yTextLength; const inlineTextLength = this.targetInlineRange.length; if (totalTextLength === inlineTextLength) { page.deleteBlock(blockElement.model); } else { this.inlineEditor.formatText(this.targetInlineRange, { link: null }); } this.abortController.abort(); } private _convertToEmbedView() { if (!this._embedOptions || this._embedOptions.viewType !== 'embed') { return; } const { flavour } = this._embedOptions; const url = this.currentLink; const blockElement = this.blockElement; const page = blockElement.page; const parent = page.getParent(blockElement.model); assertExists(parent); const index = parent.children.indexOf(blockElement.model); page.addBlock(flavour, { url }, parent, index + 1); const totalTextLength = this.inlineEditor.yTextLength; const inlineTextLength = this.targetInlineRange.length; if (totalTextLength === inlineTextLength) { page.deleteBlock(blockElement.model); } else { this.inlineEditor.formatText(this.targetInlineRange, { link: null }); } this.abortController.abort(); } private _removeLink() { if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { this.inlineEditor.formatText(this.targetInlineRange, { link: null, }); } this.abortController.abort(); } private _onKeydown(e: KeyboardEvent) { e.stopPropagation(); if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); this._onConfirm(); } } private _updateConfirmBtn() { assertExists(this.confirmButton); const link = this.linkInput?.value; this.confirmButton.disabled = !(link && isValidUrl(link)); this.confirmButton.requestUpdate(); } private _confirmBtnTemplate() { return html`${ConfirmIcon}`; } private _createTemplate() { this.updateComplete .then(() => { this.linkInput?.focus(); this._updateConfirmBtn(); }) .catch(console.error); return html` `; } private _toggleMoreMenu() { if (this._moreMenuAbortController) { this._moreMenuAbortController.abort(); this._moreMenuAbortController = null; return; } this._moreMenuAbortController = new AbortController(); const linkPopupMoreMenu = new LinkPopupMoreMenu(); linkPopupMoreMenu.abortController = this.abortController; linkPopupMoreMenu.inlineEditor = this.inlineEditor; linkPopupMoreMenu.targetInlineRange = this.targetInlineRange; linkPopupMoreMenu.host = this.blockElement.host; createLitPortal({ template: linkPopupMoreMenu, container: this.popupContainer, computePosition: { referenceElement: this.popupContainer, placement: 'top-end', middleware: [flip(), offset(4)], autoUpdate: true, }, abortController: this._moreMenuAbortController, }); } private _viewTemplate() { this._embedOptions = this._pageService.getEmbedBlockOptions( this.currentLink ); return html` `; } private _editTemplate() { this.updateComplete .then(() => { assertExists(this.textInput); this.textInput.value = this.currentText; assertExists(this.linkInput); this.linkInput.value = this.currentLink; this.textInput.select(); this._updateConfirmBtn(); }) .catch(console.error); return html` `; } override render() { const mask = this.type === 'edit' || this.type === 'create' ? html`` : nothing; const popover = this.type === 'create' ? this._createTemplate() : this.type === 'view' ? this._viewTemplate() : this._editTemplate(); return html`
${mask}
`; } } declare global { interface HTMLElementTagNameMap { 'link-popup': LinkPopup; } }