mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 21:48:48 +08:00
feat(editor): add inline packages (#11048)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user