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

This commit is contained in:
Saul-Mirone
2025-03-20 13:47:35 +00:00
parent aa620af40f
commit e5e429e7b2
170 changed files with 1337 additions and 804 deletions
@@ -0,0 +1,185 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference';
import type { ReferenceInfo } from '@blocksuite/affine-model';
import {
ParseDocUrlProvider,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import { affineTextStyles } from '@blocksuite/affine-shared/styles';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
ZERO_WIDTH_SPACE,
} from '@blocksuite/block-std/inline';
import { WithDisposable } from '@blocksuite/global/lit';
import type { DeltaInsert } from '@blocksuite/store';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
export class AffineLink extends WithDisposable(ShadowlessElement) {
static override styles = css`
affine-link a:hover [data-v-text='true'] {
text-decoration: underline;
}
`;
// The link has been identified.
private _identified: boolean = false;
// see https://github.com/toeverything/AFFiNE/issues/1540
private readonly _onMouseUp = () => {
const anchorElement = this.querySelector('a');
if (!anchorElement || !anchorElement.isContentEditable) return;
anchorElement.contentEditable = 'false';
setTimeout(() => {
anchorElement.removeAttribute('contenteditable');
}, 0);
};
private _referenceInfo: ReferenceInfo | null = null;
openLink = (e?: MouseEvent) => {
if (!this._identified) {
this._identified = true;
this._identify();
}
const referenceInfo = this._referenceInfo;
if (!referenceInfo) return;
const refNodeSlotsProvider = this.std.getOptional(RefNodeSlotsProvider);
if (!refNodeSlotsProvider) return;
e?.preventDefault();
refNodeSlotsProvider.docLinkClicked.next({
...referenceInfo,
host: this.std.host,
});
};
_whenHover = whenHover(
hovered => {
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: 'affine:link',
element: this,
setFloating: this._whenHover.setFloating,
};
return;
}
// Clears previous bindings
message$.value = null;
this._whenHover.setFloating();
},
{ enterDelay: 500 }
);
override connectedCallback() {
super.connectedCallback();
this._whenHover.setReference(this);
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
this._disposables.add(() => {
if (message$?.value) {
message$.value = null;
}
this._whenHover.dispose();
});
}
// Workaround for links not working in contenteditable div
// see also https://stackoverflow.com/questions/12059211/how-to-make-clickable-anchor-in-contenteditable-div
//
// Note: We cannot use JS to directly open a new page as this may be blocked by the browser.
//
// Please also note that when readonly mode active,
// this workaround is not necessary and links work normally.
get block() {
if (!this.inlineEditor?.rootElement) return null;
const block = this.inlineEditor.rootElement.closest<BlockComponent>(
`[${BLOCK_ID_ATTR}]`
);
return block;
}
get inlineEditor() {
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
`[${INLINE_ROOT_ATTR}]`
);
return inlineRoot?.inlineEditor;
}
get link() {
return this.delta.attributes?.link ?? '';
}
get selfInlineRange() {
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
return selfInlineRange;
}
// Identify if url is an internal link
private _identify() {
const link = this.link;
if (!link) return;
const result = this.std.getOptional(ParseDocUrlProvider)?.parseDocUrl(link);
if (!result) return;
const { docId: pageId, ...params } = result;
this._referenceInfo = { pageId, params };
}
private _renderLink(style: StyleInfo) {
return html`<a
href=${this.link}
rel="noopener noreferrer"
target="_blank"
style=${styleMap(style)}
@click=${this.openLink}
@mouseup=${this._onMouseUp}
><v-text .str=${this.delta.insert}></v-text
></a>`;
}
override render() {
const linkStyle = {
color: 'var(--affine-link-color)',
fill: 'var(--affine-link-color)',
'text-decoration': 'none',
cursor: 'pointer',
};
if (this.delta.attributes && this.delta.attributes?.code) {
const codeStyle = affineTextStyles(this.delta.attributes);
return html`<code style=${styleMap(codeStyle)}>
${this._renderLink(linkStyle)}
</code>`;
}
const style = this.delta.attributes
? affineTextStyles(this.delta.attributes, linkStyle)
: {};
return this._renderLink(style);
}
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
};
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -0,0 +1,375 @@
import { toast } from '@blocksuite/affine-components/toast';
import {
ActionPlacement,
EmbedIframeService,
EmbedOptionProvider,
FeatureFlagService,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { BlockSelection } from '@blocksuite/block-std';
import {
CopyIcon,
DeleteIcon,
EditIcon,
UnlinkIcon,
} from '@blocksuite/icons/lit';
import { signal } from '@preact/signals-core';
import { html } from 'lit-html';
import { keyed } from 'lit-html/directives/keyed.js';
import { AffineLink } from '../affine-link';
import { toggleLinkPopup } from '../link-popup/toggle-link-popup';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'link',
type: 'inline view',
};
export const builtinInlineLinkToolbarConfig = {
actions: [
{
id: 'a.preview',
content(cx) {
const target = cx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return null;
const { link } = target;
return html`<affine-link-preview .url=${link}></affine-link-preview>`;
},
},
{
id: 'b.copy-link-and-edit',
actions: [
{
id: 'copy-link',
tooltip: 'Copy link',
icon: CopyIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { link } = target;
if (!link) return;
// Clears
ctx.reset();
navigator.clipboard.writeText(link).catch(console.error);
toast(ctx.host, 'Copied link to clipboard');
ctx.track('CopiedLink', {
...trackBaseProps,
control: 'copy link',
});
},
},
{
id: 'edit',
tooltip: 'Edit',
icon: EditIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
const abortController = new AbortController();
const popover = toggleLinkPopup(
ctx.std,
'edit',
inlineEditor,
selfInlineRange,
abortController
);
abortController.signal.onabort = () => popover.remove();
ctx.track('OpenedAliasPopup', {
...trackBaseProps,
control: 'edit',
});
},
},
],
},
{
id: 'c.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
disabled: true,
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return;
// Clears
ctx.reset();
const title = inlineEditor.yTextString.slice(
selfInlineRange.index,
selfInlineRange.index + selfInlineRange.length
);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
const flavour =
options?.viewType === 'card'
? options.flavour
: 'affine:bookmark';
const index = parent.children.indexOf(model);
const props = {
url,
title: title === url ? '' : title,
};
const blockId = ctx.store.addBlock(
flavour,
props,
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.formatText(selfInlineRange, { link: null });
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return false;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return false;
// check if the url can be embedded as iframe block
const featureFlag = ctx.std.get(FeatureFlagService);
const embedIframeService = ctx.std.get(EmbedIframeService);
const isEmbedIframeEnabled = featureFlag.getFlag(
'enable_embed_iframe_block'
);
const canEmbedAsIframe =
isEmbedIframeEnabled && embedIframeService.canEmbed(url);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return canEmbedAsIframe || options?.viewType === 'embed';
},
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return;
// Clears
ctx.reset();
const index = parent.children.indexOf(model);
const props = { url };
let blockId: string | undefined;
// first try to embed as iframe block
const featureFlag = ctx.std.get(FeatureFlagService);
const isEmbedIframeEnabled = featureFlag.getFlag(
'enable_embed_iframe_block'
);
const embedIframeService = ctx.std.get(EmbedIframeService);
if (isEmbedIframeEnabled && embedIframeService.canEmbed(url)) {
blockId = embedIframeService.addEmbedIframeBlock(
props,
parent.id,
index + 1
);
} else {
// if not, try to add as other embed link block
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (options?.viewType !== 'embed') return;
const flavour = options.flavour;
blockId = ctx.store.addBlock(flavour, props, parent, index + 1);
}
if (!blockId) return;
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.formatText(selfInlineRange, { link: null });
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(actions[0].label);
const onToggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
target,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.onToggle=${onToggle}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
if (ctx.flags.isNative()) return false;
if (
target.block.closest('affine-database') ||
target.block.closest('affine-table')
)
return false;
const { model } = target.block;
const parent = model.parent;
if (!parent) return false;
const schema = ctx.store.schema;
const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark');
if (!bookmarkSchema) return false;
const parentSchema = schema.flavourSchemaMap.get(parent.flavour);
if (!parentSchema) return false;
try {
schema.validateSchema(bookmarkSchema, parentSchema);
} catch {
return false;
}
return true;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
placement: ActionPlacement.More,
id: 'b.remove-link',
label: 'Remove link',
icon: UnlinkIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.formatText(selfInlineRange, { link: null });
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.deleteText(selfInlineRange);
},
},
],
} as const satisfies ToolbarModuleConfig;
@@ -0,0 +1,2 @@
export { AffineLink } from './affine-link.js';
export { toggleLinkPopup } from './link-popup/toggle-link-popup.js';
@@ -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;
}